================================================================================ SOURCE: https://docs.sent.dm/llms/build/architecture.txt TITLE: Reference Architecture ================================================================================ URL: https://docs.sent.dm/llms/build/architecture.txt The anatomy of a production Sent integration: the layers, the outbound and inbound request flows, the HTTP surface your app exposes, and where state lives. # Reference Architecture Before any code, get the shape right. A production Sent integration is small but has a few distinct responsibilities that are worth separating. Keeping them apart is what makes the integration testable, safe to scale, and easy to reason about when something fails at 3 AM. ## The layers A production Sent integration is organized the same way, regardless of language: *In plain English: config loads first, builds a per-request client, which the service layer uses to talk to Sent; routes call the service layer; a separate webhook receiver (with its own verifier) writes to the same state store. Read top to bottom.* | Layer | Responsibility | Why it's separate | |---|---|---| | **Config & validation** | Parse and validate environment/config at startup; fail fast on misconfiguration. | Nothing downstream should defend against missing config. | | **Per-request client factory** | Turn the caller's `Authorization: Bearer` key into an SDK client for *this* request. | The key is a request credential, not global state. See [Authentication](/build/authentication). | | **Sent service layer** | A thin wrapper over the SDK: one method per business operation, snake→camel mapping, error translation. | Keeps SDK types out of your controllers and makes everything mockable in tests. | | **Routes / controllers** | HTTP surface, request validation, response shaping. | Thin. All logic lives in the service layer. | | **Webhook receiver** | Verify → acknowledge → process inbound events. | It's a public, state-mutating endpoint; it needs its own security discipline. | | **Signature verifier** | The HMAC verification primitive, isolated and unit-tested. | Security-critical and identical across every route that needs it. | | **State store** | Track message status from webhook events; hold webhook secrets; track idempotency/dedupe keys. | The only stateful part, and the part you swap for Redis/DB in production. | This isn't heavyweight. In most languages each layer is one small file. The point is that "call the SDK" and "receive a webhook" never bleed into each other, and neither one reaches directly for global credentials. ## Flow 1: Outbound (you call Sent) A client hits your API; you build a Sent client from the request's credential, call the service layer, and map the response back to your own contract. *In plain English: read each arrow top to bottom as one step in order. Your route builds a client, hands off to the service layer, which calls Sent and maps the response back. The `200` you return only means "accepted," not "delivered."* The message now exists on Sent's side in a non-terminal state (`QUEUED`). Its final outcome arrives later, asynchronously, over a webhook. That's Flow 2. ## Flow 2: Inbound (Sent calls you) As the message progresses, Sent `POST`s signed events to an endpoint you registered. Your receiver verifies, acknowledges immediately, then updates state. *In plain English: the `alt`/`else` box is an if/else. If the signature is bad or the timestamp is stale, reject with `401`; otherwise acknowledge with `200` immediately, and only then update your own state store.* Two independent flows, one shared service layer. Everything else in this guide is filling in these boxes correctly. ## The HTTP surface your integration exposes This guide standardizes on one contract so the pattern applies unchanged regardless of your backend. You don't have to expose exactly this, but it's a well-tested starting point and the rest of the guide uses these paths. | Method | Path | Purpose | |---|---|---| | `GET` | `/health` | Liveness. | | `POST` | `/api/auth/verify` | Validate a key against `GET /v3/me`. | | `POST` | `/api/messages` | Send a templated message. | | `GET` | `/api/messages/:id` | Read a tracked message's current status. | | `GET` / `POST` | `/api/contacts` | List / create contacts. | | `DELETE` | `/api/contacts/:id` | Delete a contact. | | `GET` | `/api/templates` (`/:id`) | List / fetch templates. | | `POST` | `/webhooks/sent` | **Receive** signed webhook events. | | `GET` / `POST` | `/api/webhooks` | List / register webhook endpoints. | | `PATCH` | `/api/webhooks/:id/toggle` | Turn an endpoint on / off. | | `POST` | `/api/webhooks/:id/rotate-secret` | Rotate an endpoint's signing secret. | | `DELETE` | `/api/webhooks/:id` | Remove an endpoint. | Note the two distinct webhook paths. `POST /webhooks/sent` is where Sent delivers events to you (the receiver). The `/api/webhooks*` routes are where *your* client manages which endpoints exist (management, a proxy over the SDK). They are different concerns. See [Endpoint management](/build/endpoint-management). ## Stateful vs stateless The only stateful pieces are the **message-status store**, the **held webhook secrets**, and the **idempotency/dedupe keys** that guard against duplicate sends and duplicate webhook processing. Starting with all three in-process memory is perfect for a single instance and for learning the pattern. In production you run more than one instance, so all three move to shared storage. This guide builds the in-memory version first and shows the swap in [Scaling & deployment](/build/scaling-and-deployment). **If you're on PHP-FPM, "in-process memory" doesn't mean what it does in Node, Python, or Go.** Those runtimes keep one long-lived process handling many requests, so a plain in-memory map genuinely persists between them. PHP-FPM spins up a fresh process (or reuses one with no memory of prior requests) for every single request. An in-memory store built the same way as the other languages' examples will silently be empty on the very next request. For PHP, treat "in-process" as file-backed (or Redis/DB) from day one, not as an optional later upgrade. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/authentication.txt TITLE: Authenticating Requests with Per-Request Clients ================================================================================ URL: https://docs.sent.dm/llms/build/authentication.txt Build a Sent SDK client from the request's bearer key. Per-request client factories in seven frameworks, 401 guards, key verification, and easy rotation. # Authenticating Requests with Per-Request Clients This is the foundation everything else in the integration builds on, and it's the one thing most integrations get wrong. This guide shows you how to extract the caller's API key, build a per-request SDK client in seven frameworks, guard protected routes with `401`, verify a key, and keep rotation a non-event. The rule, stated once and precisely: > The Sent API key is a **per-request credential**. It arrives as > `Authorization: Bearer `, and you use it to build an SDK client **for that request**. > It is **never** a boot-time singleton, and it is **never** a stored or required environment > variable. **Do not do this.** The most common mistake in a Sent integration: ```ts // ❌ Anti-pattern: a global client built from an env var at boot. import SentDm from "@sentdm/sentdm"; export const sent = new SentDm({ apiKey: process.env.SENT_DM_API_KEY }); ``` This bakes a single tenant's key into your process, forces a secret into your environment, makes multi-tenant serving impossible, and turns key rotation into a redeploy. There is no `SENT_DM_API_KEY` env var anywhere in this blueprint. Build the client **per request** instead. **Quick path.** If you just need this working: pick your language/framework tab under [The per-request client factory](#the-per-request-client-factory), copy the bearer-extraction function and the 401 guard, and call the factory at the top of every protected route. That's the whole pattern. The rest of this page covers key sources, `401` handling, verification, and rotation; the reasoning behind the rule lives in [About per-request credentials](/build/per-request-credentials). ## The per-request client factory The pattern is identical in every language: pull the bearer token off the request, reject with `401` if it's missing, and construct a client bound to that key. Nothing is cached across requests; nothing reads a global. ```ts import SentDm from "@sentdm/sentdm"; import type { Request } from "express"; import { ApiError } from "../types"; /** Builds SDK-backed services bound to a specific customer's API key. */ export function servicesForApiKey(apiKey: string): Services { const client = new SentDm({ apiKey }); return { sentService: new SentService(client), /* … */ }; } /** * Resolve the API key for THIS request. The ONLY valid source is * `Authorization: Bearer `. The key is never persisted. */ export function apiKeyFromRequest(req: Request): string | undefined { const header = req.header("authorization"); const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); return bearer || undefined; } /** Per-request services — throws 401 when no bearer token is present. */ export function servicesForRequest(req: Request): Services { const apiKey = apiKeyFromRequest(req); if (!apiKey) { throw new ApiError(401, "Unauthorized", "Missing API key — send it as Authorization: Bearer ."); } return servicesForApiKey(apiKey); } ``` ```ts import SentDm from "@sentdm/sentdm"; import type { NextRequest } from "next/server"; export function apiKeyFromRequest(request: NextRequest | Request): string | undefined { const header = request.headers.get("authorization"); const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); return bearer || undefined; } export class MissingApiKeyError extends Error { readonly status = 401; readonly code = "Unauthorized"; constructor() { super("Missing API key — send it as Authorization: Bearer ."); } } /** Builds an SDK client bound to a specific customer's key. */ export function clientForApiKey(apiKey: string): SentDm { return new SentDm({ apiKey, maxRetries: 2, timeout: 30_000 }); } /** Per-request client — throws MissingApiKeyError (401) when absent. */ export function clientForRequest(request: NextRequest | Request): SentDm { const apiKey = apiKeyFromRequest(request); if (!apiKey) throw new MissingApiKeyError(); return clientForApiKey(apiKey); } ``` ```python from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sent_dm import AsyncSent security = HTTPBearer(auto_error=False) async def get_optional_token( credentials: HTTPAuthorizationCredentials | None = Depends(security), ) -> str | None: return credentials.credentials if credentials else None def _resolve_api_key(token: str | None) -> str: if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key — send it as Authorization: Bearer .", ) return token def get_sent_client(token: str | None = Depends(get_optional_token)) -> AsyncSent: """Per-request SDK client bound to the customer's API key.""" api_key = _resolve_api_key(token) return AsyncSent(api_key=api_key) def get_sent_service(client: AsyncSent = Depends(get_sent_client)) -> SentService: return SentService(client) ``` ```go // ClientResolver builds a per-request SentClient from the caller's API key. // The key lives only for the duration of the request. No env fallback. type ClientResolver struct{ logger *zap.Logger } var bearerRE = regexp.MustCompile(`(?i)^Bearer\s+(.+)$`) var ErrMissingAPIKey = &models.HTTPError{ Code: http.StatusUnauthorized, ErrorCode: "UNAUTHORIZED", Message: "Missing API key — send it as Authorization: Bearer .", } func (r *ClientResolver) APIKeyFromRequest(c echo.Context) string { header := c.Request().Header.Get("Authorization") if m := bearerRE.FindStringSubmatch(strings.TrimSpace(header)); m != nil { if key := strings.TrimSpace(m[1]); key != "" { return key } } return "" } // ForRequest → SentClient bound to this request's key, or 401 when absent. func (r *ClientResolver) ForRequest(c echo.Context) (*SentClient, error) { apiKey := r.APIKeyFromRequest(c) if apiKey == "" { return nil, ErrMissingAPIKey } return NewSentClient(apiKey, r.logger), nil } func NewSentClient(apiKey string, logger *zap.Logger) *SentClient { return &SentClient{client: sentdm.NewClient(option.WithAPIKey(apiKey)), logger: logger} } ``` ```php app->singleton(...)`. */ class SentClientFactory { public function forRequest(Request $request): Client { $apiKey = $this->resolveApiKey($request); if (empty($apiKey)) { abort(401, 'Missing API key — send it as Authorization: Bearer .'); } return new Client(apiKey: $apiKey, requestOptions: ['maxRetries' => 2]); } private function resolveApiKey(Request $request): ?string { $header = (string) $request->header('Authorization', ''); if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) { $bearer = trim($m[1]); return $bearer !== '' ? $bearer : null; } return null; } } ``` **Why not the container?** `$this->app->singleton(Client::class, fn () => new Client(apiKey: config('services.sent.key')))` is the same boot-time-singleton anti-pattern from the top of this page, expressed with Laravel's container. Under standard PHP-FPM it happens to be harmless because the whole process (and the container with it) is thrown away after each request. But under **Laravel Octane**, which reuses the same process across many requests exactly like Node or Python, that singleton would freeze the *first* request's key into every request the worker ever serves after it. Resolve `SentClientFactory` fresh (`app()->bind(...)` at most, never `singleton`) and call `forRequest($request)` explicitly. Don't let the container hold the client itself. ```php /** * Builds the Sent SDK client for THIS request. The API key is a per-customer * RUNTIME value, never required at boot and never persisted. No env fallback. */ class SentClientFactory { public function __construct( private readonly RequestStack $requestStack, private readonly int $maxRetries = 2, ) {} public function create(): Client { $apiKey = $this->resolveApiKey(); if (empty($apiKey)) { throw new HttpException(401, 'Missing API key — send it as Authorization: Bearer .'); } return new Client(apiKey: $apiKey, requestOptions: ['maxRetries' => $this->maxRetries]); } private function resolveApiKey(): ?string { $request = $this->requestStack->getCurrentRequest(); $header = (string) ($request?->headers->get('Authorization') ?? ''); if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) { $bearer = trim($m[1]); return $bearer !== '' ? $bearer : null; } return null; } } ``` ```ruby class ApplicationController < ActionController::API private # Resolve the customer API key for THIS request. nil when no bearer token. def current_api_key header = request.headers["Authorization"] header&.match(/\ABearer\s+(.+)\z/i)&.captures&.first&.strip.presence end # Guard for protected /api/* routes. 401 when no bearer token is present. def require_api_key! return if current_api_key.present? render json: { error: { code: "UNAUTHORIZED", message: "Missing API key — send it as Authorization: Bearer ." } }, status: :unauthorized end # Per-request SDK client built from the resolved key. def sent_client Sentdm::Client.new(api_key: current_api_key) end end ``` Note the framework-idiomatic shapes: Express uses a plain factory function, FastAPI a `Depends` chain, Go a `ClientResolver`, Laravel a plain service class called explicitly (never container-bound, see the preceding warning), Symfony a DI-injected factory scoped to `RequestStack`, Rails a controller `before_action` guard. They all implement the same contract: build fresh from the request, never cache across one. **One regex, everywhere.** Every backend matches `^Bearer\s+(.+)$` case-insensitively, trims, and treats empty as absent. Keep that parsing in the factory so no controller re-implements it. ## Where the key comes from The factory needs a key on every request; its source depends on your deployment shape: - **Multi-tenant.** Each customer supplies their own key as `Authorization: Bearer `; extract it exactly as the factories in the preceding section do. Never share a client across tenants. - **Single-tenant.** Your own backend resolves the one key inside each request or job (from your config or secrets manager, never cached into a module-level global at boot) and hands it to the same factory. Never ship the key to a browser or mobile app. For why the pattern works this way, and what it buys you in rotation and tenant isolation, see [About per-request credentials](/build/per-request-credentials). ## No key → 401 A protected route with no bearer token must fail closed with `401`, before any SDK call. Every factory in [The per-request client factory](#the-per-request-client-factory) does exactly this. Two things to keep straight: - **Unprotected routes never gate on it.** `GET /health` and the webhook receiver `POST /webhooks/sent` don't build an SDK client, so they never call the factory and never return `401`. (The receiver has its own auth: the [signature](/build/signature-verification).) - **Fail before the network.** Reject the missing key locally; don't send an unauthenticated request to Sent just to bounce a `401` back. ## Verifying a key Before trusting a key, or to let a user confirm the one they pasted works, validate it against `GET /v3/me`. This is the canonical `POST /api/auth/verify` endpoint: it makes one authenticated call and reports whether the key is good, without leaking upstream error shapes. Two deliberate departures from the rest of this page: the route reads the key from the request body because its whole job is to test a key the user just pasted (it's not yet a trusted credential), and the upstream call sends the key in the `x-api-key` header because it calls the Sent REST API directly. The `Authorization: Bearer` shape is your API's contract with its own callers, while [Sent's API authenticates with `x-api-key`](/reference/api/authentication). In the TypeScript sample, `asyncHandler` is one of the [shared helpers](/build/project-setup#shared-helpers) defined in project setup. ```ts router.post("/verify", asyncHandler(async (req, res) => { const { apiKey } = req.body as { apiKey?: string }; if (!apiKey) { return res.status(400).json({ valid: false, error: { status: 400, code: "MissingApiKey", message: "apiKey is required" } }); } const baseUrl = process.env["SENT_BASE_URL"] ?? "https://api.sent.dm"; const response = await fetch(`${baseUrl}/v3/me`, { headers: { "x-api-key": apiKey, "Content-Type": "application/json" }, }); const data = await response.json(); if (response.ok) return res.status(200).json({ valid: true, account: data }); return res.status(200).json({ valid: false, error: { status: response.status, code: data.code ?? "ApiError", message: data.message ?? "Authentication failed" }, }); })); ``` ```python @router.post("/api/auth/verify") async def verify(body: dict): api_key = body.get("apiKey") if not api_key: return {"valid": False, "error": {"status": 400, "code": "MissingApiKey", "message": "apiKey is required"}} base_url = os.getenv("SENT_BASE_URL", "https://api.sent.dm") async with httpx.AsyncClient() as http: resp = await http.get(f"{base_url}/v3/me", headers={"x-api-key": api_key}) data = resp.json() if resp.is_success: return {"valid": True, "account": data} return {"valid": False, "error": {"status": resp.status_code, "code": data.get("code", "ApiError"), "message": data.get("message", "Authentication failed")}} ``` `verify` returns `200` with `{ valid: false, error }` for a bad key rather than propagating the upstream `401`. That's a deliberate UX choice for a "test your key" screen: the request to *your* endpoint succeeded; the *key* is what's invalid. ## Secret hygiene and rotation The key is a bearer credential: anyone holding it can send on the account. Treat it accordingly. - **Never log it.** Not in request logs, not in error payloads, not in traces. Redact the `Authorization` header at your logging boundary. - **Never persist it.** Keep it out of environment variables, config files, database columns, and disk. It exists only in the request scope. When the request ends, so does the key. The one narrow, deliberate exception is queued/async work, where the key necessarily outlives the original request. See [Errors & resilience](/build/errors-and-resilience#retries-and-exponential-backoff) for the hygiene that exception requires. - **Transport only over TLS.** The bearer token is only as safe as the channel, so require HTTPS end to end. - **Scope who can send it.** In your own front end, the key comes from the authenticated user's session or vault, attached per request, never hard-coded or shipped to the browser. - **Rotation is a non-event.** Because nothing caches the key, a customer rotates in the dashboard and sends the new key on the next request. Your integration needs no redeploy, restart, or coordinated cutover. This is the direct reward for not holding the key in a singleton. If rotating a key would require you to redeploy or restart, that's the singleton anti-pattern leaking back in. In this architecture, rotation should require **zero** changes to your running app. ## Next steps You can now build an authenticated client for any request. Put it to work on the outbound path. ================================================================================ SOURCE: https://docs.sent.dm/llms/build/contacts-and-templates.txt TITLE: Managing Contacts & Templates ================================================================================ URL: https://docs.sent.dm/llms/build/contacts-and-templates.txt Service wrappers for contacts and templates: the camelCase contract mapping, looking up templates by id or name, and decoupling your app from hard-coded IDs. # Managing Contacts & Templates This page shows you how to wrap contacts and templates in your service layer. Both follow the same shape as [sending messages](/build/sending-messages): a thin service method wraps the SDK resource, maps the snake_case response to your camelCase contract, and lets the controller stay dumb. Samples on this page are shown in TypeScript, Python, and Go; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). Concepts first: [contacts](/start/concepts/contacts) and [templates](/start/concepts/templates). Guides: [managing contacts](/start/guides/managing-contacts) and [working with templates](/start/guides/working-with-templates). This page is about *wiring* them into your service layer. ## Contacts Three operations: `contacts.list`, `contacts.create`, `contacts.delete`. The mapping is small but non-negotiable: `phone_number → phoneNumber`, `created_at → createdAt`, and it happens in the service, never in the controller. ```ts // services/sent.service.ts async listContacts(): Promise { const response = await this.client.contacts.list({ page: 1, page_size: 100 }); const contacts = response.data?.contacts ?? []; return contacts.map((c) => ({ id: c.id, phoneNumber: c.phone_number, // ← snake → camel createdAt: c.created_at, })); } async createContact(phoneNumber: string, sandbox?: boolean): Promise { const response = await this.client.contacts.create({ phone_number: phoneNumber, sandbox: sandbox ?? false, }); const c = response.data ?? {}; return { id: c.id, phoneNumber: c.phone_number, createdAt: c.created_at }; } async deleteContact(id: string): Promise { await this.client.contacts.delete(id, {}); } ``` ```python # app/services/sent_service.py async def list_contacts(self) -> CanonicalContactList: response = await self._client.contacts.list(page=1, page_size=100) contacts = response.data.contacts if response.data and response.data.contacts else [] return CanonicalContactList( items=[ CanonicalContact(id=c.id, phoneNumber=c.phone_number, createdAt=c.created_at) for c in contacts ] ) async def create_contact(self, request: CanonicalCreateContactRequest) -> CanonicalContact: response = await self._client.contacts.create( phone_number=request.phoneNumber, sandbox=request.sandbox, ) c = response.data return CanonicalContact( id=getattr(c, "id", None), phoneNumber=getattr(c, "phone_number", None), createdAt=getattr(c, "created_at", None), ) async def delete_contact(self, contact_id: str) -> None: await self._client.contacts.delete(contact_id) ``` ```go // internal/services/sentclient.go func (c *SentClient) CreateContact(ctx context.Context, phoneNumber string, sandbox bool) (*sentdm.APIResponseOfContact, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() params := sentdm.ContactNewParams{PhoneNumber: phoneNumber} if sandbox { params.Sandbox = sentdm.Bool(true) } return c.client.Contacts.New(ctx, params) } // internal/models/contract.go — map SDK contact → contract shape. func NewContactResponse(c sentdm.ContactResponse) ContactResponse { return ContactResponse{ ID: c.ID, PhoneNumber: c.PhoneNumber, // ← snake → camel at the JSON tag CreatedAt: c.CreatedAt, } } ``` The controller just delegates and picks status codes (`201` on create, `204` on delete). `validateBody` and `asyncHandler` are the [shared helpers](/build/project-setup#shared-helpers) from project setup: ```ts // controllers/contacts.controller.ts router.get('/', asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); res.status(200).json({ items: await sentService.listContacts() }); })); router.post('/', validateBody(CreateContactSchema), asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); const contact = await sentService.createContact(req.body.phoneNumber, req.body.sandbox); res.status(201).json(contact); })); router.delete('/:id', asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); await sentService.deleteContact(String(req.params.id)); res.status(204).send(); })); ``` ## Templates Templates are read-only from your integration's side. You list them and retrieve them; you don't create them here (that's a console/approval workflow). Two operations: `templates.list` and `templates.retrieve`. Two mapping gotchas to handle explicitly: - The SDK exposes **`channels`** (an array); your contract wants a single **`channel`**, so take `channels[0]`. - The SDK `Template` model carries **no rendered `body`**, so emit `null` for it. Don't invent it. ```ts // services/sent.service.ts async listTemplates(): Promise { const response = await this.client.templates.list({ page: 1, page_size: 100 }); const templates = response.data?.templates ?? []; return templates.map((t) => ({ id: t.id, name: t.name, channel: t.channels?.[0], // ← channels[] → channel status: t.status, body: null, // ← SDK has no rendered body — always null category: t.category, language: t.language, })); } async getTemplate(id: string): Promise { const response = await this.client.templates.retrieve(id); const t = response.data ?? {}; return { id: t.id, name: t.name, channel: t.channels?.[0], status: t.status, body: null, category: t.category, language: t.language, }; } ``` ```python # app/services/sent_service.py def _channel_from_template(tmpl) -> str | None: channels = getattr(tmpl, "channels", None) return channels[0] if channels else None async def list_templates(self) -> CanonicalTemplateList: response = await self._client.templates.list(page=1, page_size=100) templates = response.data.templates if response.data and response.data.templates else [] return CanonicalTemplateList( items=[ CanonicalTemplate( id=t.id, name=t.name, channel=_channel_from_template(t), # ← channels[] → channel status=t.status, body=None, # ← SDK has no rendered body — always None category=t.category, language=t.language, ) for t in templates ] ) async def get_template(self, template_id: str) -> CanonicalTemplate: response = await self._client.templates.retrieve(template_id) t = response.data return CanonicalTemplate( id=getattr(t, "id", None), name=getattr(t, "name", None), channel=_channel_from_template(t), status=getattr(t, "status", None), body=None, # ← SDK has no rendered body — always None category=getattr(t, "category", None), language=getattr(t, "language", None), ) ``` ```go // internal/models/contract.go — map SDK template → contract shape. func NewTemplateResponse(t sentdm.Template) TemplateResponse { channel := "" if len(t.Channels) > 0 { channel = t.Channels[0] // ← channels[] → channel } return TemplateResponse{ ID: t.ID, Name: t.Name, Channel: channel, Status: t.Status, Body: nil, // ← *string in the contract; SDK has no rendered body — always null Category: t.Category, Language: t.Language, } } ``` ## Looking up templates by id or name The send API accepts a template `id` **or** `name`. You don't need both. Prefer resolving by **name** and letting `templates.list` give you the id when you need it, rather than pasting UUIDs around your codebase. ```ts // Resolve a stable, human-readable name to whatever the current template is. async function resolveTemplate(sent: SentService, name: string): Promise { const templates = await sent.listTemplates(); const match = templates.find((t) => t.name === name); if (!match) throw new ApiError(404, 'TemplateNotFound', `No template named "${name}"`); return match; } ``` Don't ship hard-coded template UUIDs scattered through your business logic. In real code, map a small set of **stable names** (`welcome`, `order_update`) to templates, resolve them at the edge, and let the id float. When a template is re-approved with a new id, nothing in your code changes. Docs snippets inline an id (`templateId: 'welcome-template-id'`) only for brevity. Validate at send time, too: a template must be **approved** for the channel you're sending on. Check `status === "APPROVED"` (and that the resolved `channel` matches your intended channel) before you call `send`, so you fail with a clear error instead of a cryptic API rejection. Budget real time for approval before your go-live date. WhatsApp template approval typically takes **24-48 hours** (see [Create your first template](/start/quickstart/first-template)). SMS templates aren't gated the same way, so if you're SMS-only you can usually send immediately. Build and test against a `sandbox: true` send while waiting; don't let approval latency be a surprise on launch day. `resolveTemplate` as shown makes a full `listTemplates` round trip on every send. Fine for a first pass, but templates change rarely, so a short-TTL cache (30-60s in memory, or your shared store once you're on multiple instances) removes that extra call and its rate-limit cost from your hot send path without meaningfully delaying a template re-approval from taking effect. ## Why the wrapper matters Every method here does the same three things: call one SDK resource, map snake→camel, translate errors. That uniformity is the point. - Controllers depend on your service interface, never on SDK types, so the SDK is mockable and swappable in tests. - The contract stays stable even when the SDK's field names or shapes drift. - The client is still built **per request** (`servicesForRequest`) from the bearer key. These reads are just as scoped as the sends. See [Authentication](/build/authentication). ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/endpoint-management.txt TITLE: Managing Webhook Endpoints ================================================================================ URL: https://docs.sent.dm/llms/build/endpoint-management.txt The management surface for webhook endpoints. Register endpoints, capture the signing secret exactly once, toggle endpoints, and rotate secrets safely. # Managing Webhook Endpoints The receiver is where Sent *delivers* events. This page is the other side, the surface where your customers *manage which endpoints exist*: register a URL, turn it on or off, rotate its secret, remove it. These are two distinct concerns living at two distinct paths, and it's worth being precise: - **`POST /webhooks/sent`**: the receiver. Sent calls it. Covered in [The webhook receiver](/build/webhook-receiver). - **`/api/webhooks*`**: management. *Your client* calls it. A thin proxy over the SDK's `webhooks` resource. That's this page. The management routes proxy the SDK's `webhooks.list` / `create` / `delete` / `rotateSecret` / `toggleStatus` methods, mapping the SDK's snake_case fields to your camelCase contract. Samples on this page are shown in TypeScript, Python, and Go; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). **Developing locally?** Sent needs a publicly reachable HTTPS URL to deliver to. It can't reach `localhost`. Tunnel your local receiver with a tool like [ngrok](https://ngrok.com) (`ngrok http 3000`) or ngrok's built-in webhook forwarding, register the tunnel's URL as your endpoint, and you'll see real, signed deliveries hit your machine while you develop. Swap in your real production URL before go-live. See the [checklist](/build/going-to-production). ## Per-customer isolation Every management call is authenticated by the caller's `Authorization: Bearer `, and the SDK client is built per request from that key (see [Authentication](/build/authentication)). The SDK's `webhooks` resource is scoped to the account behind the key, so a customer only ever sees and manages *their own* endpoints. There's no cross-customer spillage to guard against. The credential boundary handles it, as long as you never build the client from a shared, stored key. ## The born-on-registration secret This is the one thing to get right here. The signing secret is **returned exactly once**, on create (and again on rotate). It is not an env var you set, and there is no API to read it back later. If you don't capture it at the moment of creation, you cannot verify that endpoint's deliveries. You'll have to rotate to get a fresh one. So the create handler does two things atomically: register the endpoint via the SDK, and hand the returned `secret` to the store your receiver verifies against. For what that store should be, see [Where the secret lives](#where-the-secret-lives). ## Registering an endpoint In the TypeScript sample, `validateBody`, `asyncHandler`, and `webhookSecretStore` are the [shared helpers](/build/project-setup#shared-helpers) defined in project setup. ```ts router.post("/", validateBody(CreateWebhookSchema), asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); const body = req.body as { url: string; eventTypes?: string[] }; const created = await sentService.createWebhook(body.url, body.eventTypes); // Hold the signing secret in memory, keyed by endpoint id, so the receiver // can look up the right secret for whichever endpoint Sent delivers to. webhookSecretStore.remember(created.id, created.secret); res.status(201).json(created); })); // In the service layer — note the snake_case → camelCase field mapping: async createWebhook(url: string, eventTypes?: string[]) { const response = await this.client.webhooks.create({ endpoint_url: url, // contract `url` → SDK `endpoint_url` event_types: eventTypes, // contract `eventTypes` → SDK `event_types` }); const w = response.data ?? {}; return { id: w.id, url: w.endpoint_url, eventTypes: w.event_types, active: w.is_active, // SDK `is_active` → contract `active` createdAt: w.created_at, secret: w.signing_secret, // SDK `signing_secret` → contract `secret` (ONCE) }; } ``` ```python @router.post("", response_model=CanonicalCreatedWebhook, status_code=201) async def create_webhook(request: Request, body: CanonicalCreateWebhookRequest, client=Depends(get_sent_client)): service = SentService(client) created = await service.create_webhook(body.url, body.eventTypes) # Hold the signing secret in memory, keyed by endpoint id, so the receiver # can look up the right secret for whichever endpoint Sent delivers to. webhook_secret_store.remember(created.id, created.secret) return created # In the service layer — snake_case (SDK) ↔ camelCase (contract): # url → endpoint_url is_active → active # eventTypes → event_types signing_secret → secret (returned ONCE) ``` ```go func (h *WebhookAdminHandler) Create(c echo.Context) error { var req models.CreateWebhookRequest // { URL, EventTypes } if err := c.Bind(&req); err != nil { return models.BindValidationError(err) } client, err := h.resolver.ForRequest(c) // per-request, key-scoped client if err != nil { return err } resp, err := client.WebhookCreate(c.Request().Context(), req.URL, req.EventTypes) if err != nil { return err } created := models.NewWebhookResponse(resp.Data) // maps endpoint_url→url, is_active→active… created.Secret = resp.Data.SigningSecret // signing_secret → secret (ONCE) // Hold the signing secret in memory, keyed by endpoint id, so the receiver // can look up the right secret for whichever endpoint Sent delivers to. h.secrets.Remember(resp.Data.ID, resp.Data.SigningSecret) return c.JSON(http.StatusCreated, created) } ``` ### Field mapping The SDK / v3 API uses snake_case; your app's contract will likely use camelCase. Map at the edge: | Contract field | SDK / v3 source | |---|---| | `url` | `endpoint_url` | | `eventTypes` | `event_types` | | `active` | `is_active` | | `createdAt` | `created_at` | | `secret` | `signing_secret` | ## Listing, toggling, deleting The rest of the surface is a straight pass-through to the SDK. List returns the caller's endpoints (never the secret; that's create/rotate only). Toggle flips `is_active` to turn off an endpoint without deleting it. Delete removes it. ```ts router.get("/", asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); res.json({ items: await sentService.listWebhooks() }); })); router.patch("/:id/toggle", asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); res.json(await sentService.toggleWebhook(String(req.params.id))); })); router.delete("/:id", asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); await sentService.deleteWebhook(String(req.params.id)); res.status(204).send(); })); ``` ```python @router.get("", response_model=CanonicalWebhookList) async def list_webhooks(request: Request, client=Depends(get_sent_client)): return await SentService(client).list_webhooks() @router.patch("/{webhook_id}/toggle", response_model=CanonicalWebhook) async def toggle_webhook(request: Request, webhook_id: str, client=Depends(get_sent_client)): return await SentService(client).toggle_webhook(webhook_id) @router.delete("/{webhook_id}", status_code=204) async def delete_webhook(request: Request, webhook_id: str, client=Depends(get_sent_client)): await SentService(client).delete_webhook(webhook_id) return Response(status_code=204) ``` ```go func (h *WebhookAdminHandler) Register(e *echo.Echo) { e.GET("/api/webhooks", h.List) e.POST("/api/webhooks", h.Create) e.PATCH("/api/webhooks/:id/toggle", h.Toggle) e.POST("/api/webhooks/:id/rotate-secret", h.RotateSecret) e.DELETE("/api/webhooks/:id", h.Delete) } func (h *WebhookAdminHandler) Toggle(c echo.Context) error { client, err := h.resolver.ForRequest(c) if err != nil { return err } resp, err := client.WebhookToggle(c.Request().Context(), c.Param("id")) if err != nil { return err } return c.JSON(http.StatusOK, models.NewWebhookResponse(resp.Data)) } ``` ## Rotating a secret Rotation exists so you can replace a signing secret that may be compromised, or replace it on a routine schedule. There is **no overlap window**: the old secret is invalidated the moment the rotate call returns, and Sent signs every subsequent delivery with the new secret only (always exactly one signature per delivery). Treat rotation like a deployment: the faster the new secret reaches the store your receiver verifies against, the fewer deliveries fail in the gap. Call rotate. The SDK returns a **new** `signing_secret` (shown once, like on create), and the old secret is already invalid. **Persist the new secret immediately, in the same handler.** Every delivery from now on is signed with it; a delivery checked only against the old secret is rejected. Rejected deliveries aren't lost. Sent retries failed deliveries, so a short gap recovers on its own. But repeated failures count against the endpoint (10 consecutive failures auto-disable it), so treat rotate-and-persist as one atomic move, not two tasks. Retire the old secret from the endpoint's candidate set on your own schedule. It never matches again, so leaving it briefly is harmless; remove it once deliveries verify against the new secret. ```ts router.post("/:id/rotate-secret", asyncHandler(async (req, res) => { const { sentService } = servicesForRequest(req); const id = String(req.params.id); const secret = await sentService.rotateWebhookSecret(id); // Persist the new secret immediately — Sent already signs with it. The old // entry in the set is harmless now; retire it once new-secret deliveries verify. webhookSecretStore.remember(id, secret); res.json({ secret }); })); ``` ```python @router.post("/{webhook_id}/rotate-secret", response_model=CanonicalWebhookSecret) async def rotate_webhook_secret(request: Request, webhook_id: str, client=Depends(get_sent_client)): rotated = await SentService(client).rotate_webhook_secret(webhook_id) # Persist the new secret immediately — Sent already signs with it. The old # entry in the set is harmless now; retire it once new-secret deliveries verify. webhook_secret_store.remember(webhook_id, rotated.secret) return rotated ``` ```go func (h *WebhookAdminHandler) RotateSecret(c echo.Context) error { client, err := h.resolver.ForRequest(c) if err != nil { return err } resp, err := client.WebhookRotateSecret(c.Request().Context(), c.Param("id")) if err != nil { return err } // Persist the new secret immediately — Sent already signs with it. The old // entry in the set is harmless now; retire it once new-secret deliveries verify. h.secrets.Remember(c.Param("id"), resp.Data.SigningSecret) return c.JSON(http.StatusOK, models.WebhookSecretResponse{Secret: resp.Data.SigningSecret}) } ``` The verifier holds a *set* of candidate secrets per endpoint so that adding the new secret and retiring the old one don't have to be coordinated in a single step. See the rotation section in [Signature verification](/build/signature-verification). ## Where the secret lives A simple starting point is holding the secret in process memory, except in **PHP**, where each request runs in a fresh FPM worker with no shared process state, so the secret store needs to be **file-backed** on disk instead. Both are stand-ins. In production, persist the secret to your datastore keyed by the endpoint id, and load it per delivery in the receiver. Process memory doesn't survive a restart and isn't shared across instances. A second instance would reject every delivery it can't find a secret for. Never log the secret at any point in this flow. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/errors-and-resilience.txt TITLE: Error Handling & Resilience ================================================================================ URL: https://docs.sent.dm/llms/build/errors-and-resilience.txt Turn SDK exceptions into your own error envelope, catch the right exception types per language, and build in retries, rate-limit backoff, and idempotency. # Error Handling & Resilience The SDK throws its own exception types. Your API shouldn't leak them. The pattern is the same everywhere: catch the SDK error in the service layer, translate it into **one internal error type** with a status code and a stable code, and let a single handler at the edge render it as JSON. Then layer on the resilience concerns (retries, rate limits, idempotency, timeouts), most of which the SDK already handles if you let it. The SDK exception types are shown in all seven SDK languages below; the surrounding samples use a representative subset and transfer directly to the other [supported SDK languages](/sdks). Pair this with the [error-handling guide](/start/guides/error-handling) for the catalog of API error codes. This page is about *structuring* your integration to handle them. ## One internal error type Define a single `ApiError` (status code + machine code + message + optional details). Every service method catches the SDK exception and rethrows as this. The controllers never deal with it; one error handler renders it. ```ts // types/index.ts export class ApiError extends Error { constructor( public readonly statusCode: number, public readonly code: string, message: string, public readonly details?: Record, ) { super(message); this.name = 'ApiError'; } } ``` ```python # exceptions.py class SentServiceException(Exception): def __init__(self, message: str, status_code: int = 500): self.message = message self.status_code = status_code super().__init__(self.message) ``` ```go // internal/models — a single HTTP-shaped error the edge can render. type HTTPError struct { Code int `json:"-"` ErrorCode string `json:"code"` Message string `json:"message"` } var ErrMissingAPIKey = &HTTPError{ Code: http.StatusUnauthorized, ErrorCode: "UNAUTHORIZED", Message: "Missing API key — send it as Authorization: Bearer .", } ``` ## Catch specific SDK exception types Both the TypeScript and Python SDKs expose the same hierarchy: a base `APIError` with typed subclasses per status: `BadRequestError` (400), `AuthenticationError` (401), `NotFoundError` (404), `RateLimitError` (429), and a timeout error (`APIConnectionTimeoutError` in TypeScript, `APITimeoutError` in Python). Catch what you can act on differently; fall through to the base for everything else. These are real SDK-exported types, not app code. See each language's "Error handling" section in the [SDK reference](/sdks) for the full, authoritative hierarchy (Go, Java, C#, PHP, and Ruby each expose their own idiomatic shape, shown in the tabs below). ```ts // services/sent.service.ts — translate the SDK error, don't leak it. import { APIError as SentApiError } from '@sentdm/sentdm'; private toApiError(error: unknown, fallbackMessage: string): ApiError { this.logger.error({ error }, fallbackMessage); if (error instanceof SentApiError) { const e = error as { status?: number; name: string; message: string; headers?: unknown }; // e.name is BadRequestError | AuthenticationError | RateLimitError | … return new ApiError(e.status || 500, e.name, e.message, { headers: e.headers as Record | undefined, }); } return new ApiError(500, 'InternalError', fallbackMessage); } ``` ```python # exceptions.py — one handler maps every SDK APIError to your envelope. from sent_dm import APIError async def sent_api_exception_handler(request: Request, exc: APIError) -> JSONResponse: # The Python SDK exposes `status_code` (not `status`); the class name is the label. status_code = {400: 400, 401: 401, 403: 403, 404: 404, 422: 422, 429: 429}.get( getattr(exc, "status_code", 500), 500 ) return JSONResponse( status_code=status_code, content={ "error": type(exc).__name__, "message": str(exc), "status_code": status_code, }, ) # For per-call handling, catch the typed subclasses: # from sent_dm import BadRequestError, AuthenticationError, RateLimitError, APIError ``` ```go // The SDK returns a typed *sentdm.Error; unwrap it to read StatusCode. result, err := client.Messages.Send(ctx, params) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { switch apiErr.StatusCode { case http.StatusTooManyRequests: return &models.HTTPError{Code: 429, ErrorCode: "RATE_LIMITED", Message: apiErr.Error()} case http.StatusUnauthorized: return &models.HTTPError{Code: 401, ErrorCode: "UNAUTHORIZED", Message: apiErr.Error()} } } return fmt.Errorf("failed to send message: %w", err) } ``` ```java // exception/GlobalExceptionHandler.java — @RestControllerAdvice maps the // SDK's exported types (dm.sent.errors.*) to RFC 7807 ProblemDetail. import dm.sent.errors.RateLimitException; import dm.sent.errors.SentException; @ExceptionHandler(RateLimitException.class) // SDK 429 — act on it differently public ResponseEntity handleRateLimit(RateLimitException ex) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS); problem.setTitle("Rate Limited"); problem.setDetail(ex.getMessage()); return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(problem); } @ExceptionHandler(SentException.class) // fall through to the SDK base type public ResponseEntity handleSentException(SentException ex) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY); problem.setTitle("Messaging Provider Error"); problem.setDetail(ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(problem); } ``` ```csharp // Middleware/GlobalExceptionMiddleware.cs — map SentApiException → status. private static (int Status, string Title, string Detail) MapSdkException(SentApiException ex) { var status = ex.StatusCode switch { HttpStatusCode.Unauthorized => StatusCodes.Status401Unauthorized, HttpStatusCode.NotFound => StatusCodes.Status404NotFound, HttpStatusCode.Conflict => StatusCodes.Status409Conflict, HttpStatusCode.UnprocessableEntity=> StatusCodes.Status422UnprocessableEntity, HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests, _ => StatusCodes.Status502BadGateway, }; return (status, "Sent API Error", ex.Message); } ``` ```php // app/Services/SentDM/SentDMService.php — catch the SDK's typed exceptions, // rethrow your one internal error type; the edge handler renders it as JSON. use SentDm\Core\Exceptions\APIException; use SentDm\Core\Exceptions\BadRequestException; use SentDm\Core\Exceptions\RateLimitException; try { $response = $this->client->messages->send(/* … */); // … map response … } catch (RateLimitException $e) { throw new ApiError(429, 'RATE_LIMITED', $e->getMessage()); // SDK 429 } catch (BadRequestException $e) { throw new ApiError(400, 'INVALID_REQUEST', $e->getMessage()); // SDK 400 } catch (APIException $e) { Log::error('Sent API call failed', ['error' => $e->getMessage()]); throw new ApiError(502, 'UPSTREAM_ERROR', $e->getMessage()); // SDK base } ``` ```ruby # app/services/sent_dm/base_service.rb — rescue the SDK's typed errors # (Sentdm::Errors::*), rethrow your one internal error type. def send_message(params) # … client.messages.send(**params) … rescue Sentdm::Errors::RateLimitError => e raise ApiError.new(status: 429, code: "RATE_LIMITED", message: e.message) rescue Sentdm::Errors::BadRequestError => e raise ApiError.new(status: 400, code: "INVALID_REQUEST", message: e.message) rescue Sentdm::Errors::APIError => e # fall through to the SDK base type Rails.logger.error "[SentDM] #{e.class}: #{e.message}" raise ApiError.new(status: 502, code: "UPSTREAM_ERROR", message: e.message) end # One edge handler renders it: `rescue_from ApiError` in ApplicationController. ``` ## Validation errors are yours, not the SDK's Reject malformed input *before* it reaches the SDK. Validate the body against a schema and emit a `400`/`422` with field-level detail. This keeps garbage off the wire and gives clients actionable errors. ```ts // middleware/validate.ts if (error instanceof ZodError) { const details = error.errors.map((e) => ({ path: e.path.join('.'), message: e.message })); next(new ApiError(400, 'ValidationError', 'Request validation failed', { errors: details })); } ``` ## Retries and exponential backoff **The SDKs retry transient failures automatically.** Connection errors, timeouts, and `429`/`5xx` responses are retried with exponential backoff out of the box. You configure the ceiling, not the loop. Set `maxRetries` (and a request timeout) when you build the client. The SDK defaults to **2** retries with a **60 s** timeout; tightening the timeout to something like **30 s** is a reasonable choice for a user-facing request path where you'd rather fail fast and let your own retry/queue logic take over. ```ts // lib/sent/client.ts export function clientForApiKey(apiKey: string): SentDm { return new SentDm({ apiKey, maxRetries: 2, timeout: 30 * 1000, }); } ``` ```php // SentClientFactory.php — maxRetries is set via requestOptions (default 2). return new Client( apiKey: $apiKey, requestOptions: ['maxRetries' => $this->maxRetries], ); ``` ```go // Per-call deadline; the SDK handles retry of transient failures internally. ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() response, err := c.client.Messages.Send(ctx, params) ``` For long-running or bulk work, push sends onto a **queue** and let the worker own the retry policy. A Celery task, for example, can retry up to 3 times with backoff and classify errors as retryable vs terminal: A queue worker has no inbound HTTP request to pull a bearer key from, so the per-request rule still applies. It's just the request that moved. Enqueue the caller's API key (or a reference to it) as **task payload** alongside the send parameters, and build `sent_client` from that payload inside the task, never from a boot-time singleton or a worker-wide env var. This is the **one deliberate exception** to "never persist the key" (see [Authentication](/build/authentication#secret-hygiene-and-rotation)): the payload sits in the broker for as long as the job is queued. Treat it accordingly: use a broker that encrypts at rest, keep task payloads out of any queue-monitoring dashboard or DLQ viewer that isn't itself access-controlled, and set a short result/message TTL so failed jobs don't retain the key indefinitely. If a tenant's key rotates, in-flight jobs enqueued with the old key will still use it until they drain; size your retry window and TTL accordingly. ```python # tasks/messages.py — api_key arrives as task payload, not a worker singleton. from sent_dm import APIError, BadRequestError, RateLimitError, Sent, UnprocessableEntityError @shared_task(bind=True, max_retries=3, default_retry_delay=60) def send_single_message(self, api_key, phone_number, template_id, variables=None, channel=None, **_): try: sent_client = Sent(api_key=api_key) # built from this task's payload, per invocation result = sent_client.messages.send(to=[phone_number], template={...}, channel=channel) # … except RateLimitError as exc: retry_after = 60 * (2 ** self.request.retries) # exponential backoff raise self.retry(exc=exc, countdown=retry_after) # 429 → back off, then retry except (BadRequestError, UnprocessableEntityError) as exc: raise NonRetryableError(f"Invalid request: {exc}", "INVALID_REQUEST") from exc # terminal except APIError as exc: raise self.retry(exc=exc) # transient (timeout, 5xx) → bounded retry, then surface ``` Only retry **transient** failures (timeouts, `429`, `5xx`). Never blindly retry a `400`/`422`. The request is malformed and will fail every time. Retrying a bad request just amplifies the error and burns rate-limit budget. ## Rate limits and `Retry-After` A `429` (`RateLimitError`) means back off. The SDK's built-in retry already honors the `Retry-After` header for you. If you surface the `429` to your own client, for example from a queue worker that has exhausted its retries, propagate a `Retry-After` so *they* can back off too, using the same exponential schedule (`60 * 2^attempt`). ## Idempotency for sends Retries (yours or the SDK's) mean a send can be attempted more than once. Guard against duplicate deliveries with an **idempotency key**: a stable key derived from the business event (order id, notification id), stored before you call `send`. If a retry comes in for a key you've already sent, short-circuit and return the recorded result instead of sending again. This is the outbound mirror of the **inbound** dedupe you do on `message_id` + `message_status` (not `X-Webhook-ID`; that's the endpoint id, identical on every delivery) in [Status tracking](/build/status-tracking). This key needs the same shared, persistent store as the message-status store. An in-memory map has the identical failure mode: it's lost on restart, and a retry that lands on a *different* instance than the original attempt won't see it, so you send twice anyway. A database unique constraint on the idempotency key, or Redis with a TTL long enough to cover your retry window, both work. See [Idempotency across instances](/build/scaling-and-deployment#idempotency-across-instances). ## Timeouts Always bound the call: a **30 s** timeout is a reasonable default; in Go, wrap every SDK call in a `context.WithTimeout(ctx, 30*time.Second)`. A timeout surfaces as a retryable connection error (`APIConnectionTimeoutError` in TypeScript, `APITimeoutError` in Python), so the SDK's retry logic picks it up first, until retries are exhausted, at which point it surfaces as the timeout error your handler maps. ## The whole chain 1. **Validate** input → `400`/`422` before the SDK sees it. 2. **Call** the SDK with a bounded timeout and a `maxRetries` cap. 3. The **SDK retries** transient failures (timeouts, `429`, `5xx`) with backoff, honoring `Retry-After`. 4. **Catch** the typed SDK exception in the service; translate to your `ApiError`. 5. **Render** it once at the edge as consistent JSON. 6. For bulk/async work, do all of the preceding **in a queue worker** with its own retry budget and idempotency keys. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/going-to-production.txt TITLE: Going to Production ================================================================================ URL: https://docs.sent.dm/llms/build/going-to-production.txt The go-live checklist for a Sent integration. Credentials, verification, resilience, statelessness, security, monitoring, and a key/secret rotation runbook. # Going to Production Everything in this section, distilled into one gate. Walk it top to bottom before flipping traffic on. Each item links back to the page that explains the "why"; if any line surprises you, read that page first. This is the section-wide checklist. For a webhook-receiver-only pass with copy-paste snippets, the [webhook production checklist](/start/webhooks/production-checklist) drills deeper into that one endpoint. ## Credentials & authentication - [ ] **API key is per-request.** The SDK client is built from the incoming `Authorization: Bearer ` on every request. No `SENT_DM_API_KEY` env var, no boot-time singleton. See [Authentication](/build/authentication). - [ ] **No bearer token → `401`.** Requests without a valid key never reach the service layer. - [ ] **The key is never logged or persisted.** Not in logs, not in error messages, not in a database. ## Webhook verification & replay protection - [ ] **Every delivery is verified** against the HMAC scheme on the **raw body**, before JSON parsing. See [Signature verification](/build/signature-verification). - [ ] **Constant-time compare** against `X-Webhook-Signature`. - [ ] **Replay window enforced:** reject if `|now − X-Webhook-Timestamp| > 300s`. - [ ] **Rotation ready:** the verifier loads candidate secrets per endpoint at delivery time, so a rotated secret takes effect without a code deploy. Sent signs each delivery with exactly one signature, using the current secret only. - [ ] **The signing secret is never logged.** ## Resilience & idempotency - [ ] **Acknowledge fast.** The receiver returns `2xx` within the endpoint's timeout, then processes. See [Webhook receiver](/build/webhook-receiver). - [ ] **Alerting on receiver failures.** You'll know before 10 consecutive failures automatically turn off the endpoint, not after. See [Webhook receiver](/build/webhook-receiver). - [ ] **Idempotent processing.** Dedupe key derived from the payload (`message_id` + `message_status`), **not** from `X-Webhook-ID` (that's the config UUID). See [Status tracking](/build/status-tracking). - [ ] **Forward-only status.** Out-of-order and duplicate events can't regress or double-apply status; terminal states lock. - [ ] **Errors are mapped**, not leaked: the service layer translates SDK failures into your own error contract. See [Errors & resilience](/build/errors-and-resilience). ## Stateless deployment - [ ] **Message-status store is shared** (Redis/DB), not in process memory, so a send on one instance and its webhook on another agree. See [Scaling & deployment](/build/scaling-and-deployment). - [ ] **Webhook secrets are shared and readable by every instance** that can receive a delivery. - [ ] **Inbound dedupe store is shared**, so the same webhook event isn't processed twice across instances. See [Webhook receiver](/build/webhook-receiver). - [ ] **Outbound idempotency-key store is shared**, so a retried send doesn't double-send when the retry lands on a different instance. See [Errors & resilience](/build/errors-and-resilience). - [ ] **Graceful shutdown** on `SIGTERM`/`SIGINT` drains in-flight requests with a hard timeout cap. - [ ] **Health probes wired:** `/live` (pure process check) and `/ready` (checks shared deps) are distinct. ## Security & network - [ ] **HTTPS only.** TLS terminated, `http→https` redirect, HSTS set; webhook URL registered as `https://`. See [Security](/build/security). - [ ] **No leftover local tunnel URL.** If you developed against ngrok or similar, the registered endpoint now points at your real production URL, not a dev tunnel. See [Endpoint management](/build/endpoint-management). - [ ] **Security headers** on (helmet or equivalent). - [ ] **CORS locked** to your own front-end origins (no wildcard in prod), and you're not relying on CORS to protect the receiver. - [ ] **Input validated at the edge** (zod/pydantic); malformed bodies `400`, never reach the SDK. - [ ] **Rate limits configured**: a global limiter plus a stricter one on the receiver; backed by shared storage if you need a hard cap across instances. ## Logging & monitoring - [ ] **Structured (JSON) logs to stdout**, with a request/correlation ID (`X-Request-Id`) on every line. See [Observability](/build/observability). - [ ] **No secrets or PII in logs**: redact raw headers, raw bodies, and recipient numbers. - [ ] **Levels chosen by outcome** (`2xx`→debug, `4xx`→warn, `5xx`→error). - [ ] **Metrics emitted:** sends, webhooks received/rejected, failures (counters); latencies (histograms); queue depth if applicable. - [ ] **Alerts wired** on error rate and webhook rejection rate; tracing hooks (OpenTelemetry) in place if used. ## Final pre-launch - [ ] **`sandbox` is OFF for production traffic**. Verify no live path sets `sandbox: true`, and test harnesses that do can't leak in. See [Testing](/build/testing). - [ ] **Test suite green:** service-layer units, route integration tests, and the webhook verifier's known-vector test. - [ ] **Dashboard config confirmed** in the Sent dashboard: correct endpoint URL, selected event types, endpoint enabled, a test delivery succeeds. - [ ] **Rotation runbook exists** (the *Key & secret rotation runbook* section on this page) and the team knows where it is. ## Key & secret rotation runbook Two independent credentials rotate independently. Document both so a 3 AM rotation isn't improvised. **API key (compromised or scheduled)** 1. Issue a new key in the Sent dashboard. 2. Roll it out to callers. Because the key is a per-request credential, this is a client/caller change, **not** a redeploy of your integration. 3. Confirm traffic on the new key (watch auth `401` rates), then revoke the old key. **Webhook signing secret (compromised or scheduled)** 1. Rotate via `POST /api/webhooks/:id/rotate-secret`. Sent returns the new secret once, and the old secret is invalid **immediately**; every subsequent delivery is signed only with the new secret. See [Endpoint management](/build/endpoint-management). 2. Persist the new secret to shared storage in the same operation, so every instance can verify the very next delivery. Deliveries that land before the new secret is live fail verification; Sent retries them, but 10 consecutive failures auto-disable the endpoint. 3. Retire the old secret from the verifier's candidate set once deliveries verify against the new one. Leaving it briefly is harmless: it never matches again. 4. Never log either secret at any step. There is no dual-signing overlap. Sent signs with exactly one secret at a time, and rotation swaps it the moment the call returns, so "rotate, then persist at once" is the only safe order. Every delivery in the gap between those two steps is rejected and retried; keep the gap to seconds, not a deploy cycle. All boxes checked? Enable the endpoint in the dashboard and watch closely for the first 24 hours: auth failures, webhook rejections, and send error rates are your early-warning signals. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build.txt TITLE: Build with Sent ================================================================================ URL: https://docs.sent.dm/llms/build.txt # Build with Sent The production blueprint for integrating Sent: architecture, the outbound and inbound paths, and everything you need to ship a resilient, secure integration. Boxes, KeyRound, Send, Webhook, ShieldCheck, Rocket, } from "lucide-react"; # Build with Sent This is the production blueprint for integrating Sent: the opinionated, end-to-end way to architect and ship a real messaging integration, not a pile of disconnected snippets. Where the [Concepts](/start/concepts) explain *what* Sent is and the [SDK reference](/sdks) documents *every method*, this section shows you how the pieces fit together into an app you'd be comfortable running in production: how to structure the code, handle credentials safely, send messages, receive and verify webhooks, track delivery, and harden the whole thing before go-live. The code samples in these pages are written against the current, official Sent SDKs and the v3 API. The security-critical code (the send call, the signature verifier, the webhook receiver's raw-body handling) is shown in all seven SDK languages: TypeScript, Python, Go, Java, C#, PHP, Ruby. Supporting samples use a representative subset you can adapt to your stack. ## Who this is for Engineers integrating Sent into a real backend. You know your language and framework; you want the *right* way to wire Sent in (the architecture, the security boundaries, and the failure modes) without rediscovering them the hard way. Everything is framework-agnostic and shown in multiple languages via tabs; apply the pattern to your stack. ## How to read this The section follows the path you actually build in. Read it straight through the first time, then use it as a reference. } description="The layered architecture, project setup, and per-request API key handling (the part most integrations get wrong)." /> } description="The outbound path: a clean service layer over messages.send, contacts and templates, and resilient error handling." /> } description="The inbound path: a verified receiver, the exact signature scheme, endpoint management, and closing the delivery-status loop." /> } description="Security, observability, scaling a stateless deployment, testing, and a go-live checklist." /> ## The mental model A Sent integration has two independent flows, and most of this guide is about building each one well: - **Outbound**: your app calls Sent to send templated messages. The work is structuring a thin, testable service layer over the SDK and mapping errors into your own API surface. - **Inbound**: Sent calls *you* over webhooks as messages progress (sent → delivered → read / failed) and when recipients reply. The work is verifying those requests are authentic, acknowledging fast, and updating your own state idempotently. *In plain English: your app calls Sent to send (top path), and Sent calls your webhook receiver back with delivery events (bottom path). Two separate directions, one shared service layer in between.* ## Three principles this guide is built on } description="Build the SDK client per request from an Authorization: Bearer header. Never bake a key into a boot-time singleton or a stored env var. This is the single most common thing to get wrong. See Authentication." /> } description="It's a public URL that mutates your state. Verify the signature against the raw body before doing anything else, every time." /> } description="Keep per-request and per-event state out of process memory in production so you can scale horizontally. We show the in-memory pattern, then how to productionize it." /> ## Start small, then scale This guide shows the full production shape up front so you can see where everything ends up. But that shape is a **ceiling, not a floor**. If you're shipping a single-tenant app on one instance this week, you don't need Redis, a queue, Kubernetes, or distributed tracing to send a correct first message: - **One Sent account, no reseller model?** The per-request client pattern still applies (see [Authentication](/build/authentication) for exactly what it buys you), but you can skip the multi-tenant reasoning. There's no second tenant to isolate. - **One running instance?** In-memory state for message status, webhook secrets, and idempotency/dedupe keys is fine to start and fine to ship with. [Scaling & deployment](/build/scaling-and-deployment) is what you read *before* you add a second instance, not before your first deploy. - **No background jobs yet?** The queue-worker patterns in [Errors & resilience](/build/errors-and-resilience) are for when sends become long-running or bulk. Skip them until you actually have a queue. Everything else (structuring the service layer, verifying webhooks, mapping delivery status) applies from message one and isn't optional. ## Next steps - Start with the [reference architecture](/build/architecture) to see the whole shape before the details. - New to the platform? Do the [Quickstart](/start/quickstart) first, then come back here. ================================================================================ SOURCE: https://docs.sent.dm/llms/build/observability.txt TITLE: Instrumenting Logs, Metrics & Traces ================================================================================ URL: https://docs.sent.dm/llms/build/observability.txt Structured logging, request-ID correlation, log levels, and metrics and tracing hooks for a Sent integration, with keys, secrets, and payloads kept out of logs. # Instrumenting Logs, Metrics & Traces When a message doesn't arrive, you need to answer one question fast: *where in the two flows did it stop?* Good observability makes that a single log query. The rules are the usual ones (structured logs, a correlation ID on every request, sensible levels), with one hard constraint specific to this integration: **never log the API key, the signing secret, or message payloads.** Samples on this page are shown in TypeScript and Python; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). ## Structured logging Log JSON, not free text. In TypeScript, `pino` is a good default: JSON in production, pretty-printed in development, with a `base` that stamps every line with the pid and environment. ```ts // src/config/logger.ts import pino from 'pino'; import { env } from './env'; export const logger = pino({ level: env.LOG_LEVEL, transport: env.NODE_ENV === 'development' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, // JSON to stdout in production base: { pid: process.pid, env: env.NODE_ENV }, }); ``` ```python # src/app/utils/logging.py import logging from pythonjsonlogger import jsonlogger # pip install python-json-logger def configure_logging(level: str = "INFO") -> None: handler = logging.StreamHandler() # JSON to stdout — no log files handler.setFormatter( jsonlogger.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") ) logging.basicConfig( level=getattr(logging, level.upper(), logging.INFO), handlers=[handler], ) # Every `extra={...}` field now lands as a queryable JSON key. ``` Write logs to stdout as JSON and let your platform (the container runtime, a log shipper, or your host) collect them. Don't manage log files inside the app. A stateless service shouldn't own local state, including logs. See [Scaling & deployment](/build/scaling-and-deployment). ## Correlation: a request ID on every request Attach a unique ID to each inbound request, echo it back in an `X-Request-Id` header, and include it in every log line for that request. That single field lets you reconstruct a whole request from scattered log lines, and lets a client quote it in a support ticket. ```ts // src/middleware/request-logger.ts export function requestLogger(req, res, next) { const start = Date.now(); const requestId = randomUUID(); res.setHeader('X-Request-Id', requestId); logger.trace( { requestId, method: req.method, path: req.path, ip: req.ip }, 'Incoming request', ); res.on('finish', () => { const logData = { requestId, method: req.method, path: req.path, statusCode: res.statusCode, duration: `${Date.now() - start}ms`, }; // Level chosen from the outcome — see below. if (res.statusCode >= 500) logger.error(logData, 'Request failed'); else if (res.statusCode >= 400) logger.warn(logData, 'Request failed'); else logger.debug(logData, 'Request completed'); }); next(); } ``` ```python # src/app/middleware.py class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-ID"] = request_id return response class LoggingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): start = time.time() request_id = getattr(request.state, "request_id", "unknown") response = await call_next(request) logger.info("Request completed", extra={ "request_id": request_id, "method": request.method, "path": request.url.path, "status_code": response.status_code, "duration_ms": round((time.time() - start) * 1000, 2), }) return response ``` Honor an inbound `X-Request-Id` if the caller already set one (as shown in the preceding Python snippet), so the ID flows across service boundaries. Generate a fresh one only when none is present. ## What to log at each level Pick the level from the outcome, not the code path. The preceding request logger is the template: `2xx` → `debug`, `4xx` → `warn`, `5xx` → `error`. | Level | Use for | Example | |---|---|---| | `trace` / `debug` | Normal request lifecycle, health checks. | "Incoming request," "Request completed" | | `info` | Business milestones worth keeping. | "Message sent" (with `message_id`), "Webhook endpoint registered" | | `warn` | Handled problems / client errors. | `4xx` responses, an unverified webhook rejected `401` | | `error` | Unhandled failures needing attention. | `5xx` responses, SDK call threw | | `fatal` | Process-ending conditions. | Uncaught exception, unhandled rejection | ## Logging webhook processing (without secrets) The receiver is where observability and security collide. Log enough to trace a delivery, and nothing that leaks. ```ts // Safe: identifiers and outcomes only. logger.info( { requestId, webhookId: req.headers['x-webhook-id'], // config UUID, not per-event eventType: req.body.event, // e.g. "message.delivered" messageId: payload.message_id, status: payload.message_status, }, 'Webhook processed', ); // NEVER: logger.info(req.headers) → leaks X-Webhook-Signature + secret material // NEVER: logger.debug(req.body) → leaks recipient numbers + message content ``` ```python logger.info("Webhook processed", extra={ "request_id": request_id, "webhook_id": request.headers.get("x-webhook-id"), "event_type": payload["event"], "message_id": payload["payload"]["message_id"], "status": payload["payload"]["message_status"], }) # Never log request.headers or the raw body. ``` `X-Webhook-ID` is the webhook **configuration** UUID, not a per-event ID. It's the same across every delivery from one endpoint. It's useful context, but derive your idempotency/dedupe key from the payload (`message_id` + `message_status`), not from this header. See [Status tracking](/build/status-tracking). ## Metrics and tracing Logs tell you what happened to one request; metrics tell you the shape of the system. The [go-live checklist](/build/going-to-production) gates on these baseline metrics, so emit at least: - **Counters:** messages sent, webhooks received, webhooks rejected (bad signature / stale timestamp), send failures. - **Histograms:** SDK call latency, webhook processing duration, request duration (you already compute it in the request logger). - **Gauges:** if you offload webhook work to a queue, its depth. Distributed tracing, unlike the baseline metrics, is optional: skip it until you're actually debugging a cross-service latency problem the preceding logs and metrics can't answer. At that point, instrument with [**OpenTelemetry**](https://opentelemetry.io/), a vendor-neutral standard for recording timed operations (**spans**) and stitching them into one end-to-end **trace** per request, even across services. Both flows here are natural spans: wrap the `messages.send` SDK call in a span on the outbound path, and start a span in the receiver keyed by the correlation ID on the inbound path. Carry the same request/correlation ID as **baggage** (OpenTelemetry's term for a small piece of context attached to a trace and automatically propagated to every span within it), so a log line and a span can be correlated back to each other. Keep instrumentation in middleware and the service layer, not in controllers. The [layered architecture](/build/architecture) means one span around the service method covers every route that calls it. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/per-request-credentials.txt TITLE: About Per-Request Credentials ================================================================================ URL: https://docs.sent.dm/llms/build/per-request-credentials.txt Why the Sent API key is a per-request credential, never a boot-time singleton. Who sends the bearer key in each setup, and why isolation is structural. # About Per-Request Credentials The [Authentication guide](/build/authentication) is built on one rule: the Sent API key arrives as `Authorization: Bearer `, becomes an SDK client for that request, and is gone when the request ends. It is never a boot-time singleton and never a stored environment variable. This page explains why the pattern exists, who actually sends the bearer key in each deployment shape, and why it makes tenant isolation structural rather than something you remember to enforce. ## Why per-request Three concrete payoffs, not dogma: - **Multi-tenant by default.** One deployment can serve many customers, each with their own key, because the credential travels with the request instead of living in the process. - **No secret at rest in your app.** The key is never written to your environment, config, or disk. Your app is a pass-through; it holds the key only for the microseconds a request is in flight. - **Rotation is free.** A customer rotates their key in the dashboard and sends the new one on the next request. Nothing to redeploy, no restart. The underlying idea is not unique to Sent. The key is a [bearer credential](/start/concepts/api-authentication), and a bearer credential in a backend is analogous to a session token in a web app: it travels with each request and is resolved fresh each time, so no request depends on state left behind by an earlier one. A per-request client is what that model looks like inside your own service. ## Who sends the bearer key The `Authorization: Bearer ` used throughout the Authentication guide is the *shape* of the pattern; who supplies the key depends entirely on whether you have one Sent account or many. **Multi-tenant / reseller.** Each of *your* customers has their own Sent account and key. Here, "the caller" is genuinely external (your customer's own backend, or their session in your dashboard), and they legitimately hand you their Sent key on each call because it's theirs. **Single-tenant (the common case).** You have exactly one Sent account, so there is no external party who owns a Sent key, and a key shipped to a browser or mobile app is a key published. "Per-request" here means your *own* backend resolves the one key for each request or job, rather than baking it into a global at boot. In the single-tenant case, what matters is *when* the key is read, not what system it's read from. Reading it from your normal config or env on each request (not cached into a module-level variable at import or boot time) is a perfectly reasonable starting point, and it still delivers the real payoffs: rotation without a redeploy, and no key frozen into a long-lived process. A dedicated secrets manager earns its operational cost when you need cross-service rotation: one place to change a credential that many services read. Until then, per-request resolution from ordinary config gives you the same properties with less machinery. ## Why tenant isolation is structural Because the client is constructed from the request's key and discarded when the request ends, there is no shared client and no way for one tenant's calls to go out under another tenant's key. Isolation doesn't depend on every developer remembering to scope every call; the shape of the code makes the failure impossible. A boot-time singleton silently forfeits this: every request goes out under whatever single key the process started with, which makes multi-tenant serving impossible and turns key rotation into a redeploy. The per-request factory is what keeps tenant A's messages from being sent on tenant B's account. Single-tenant apps get the mirror image of the same property: because nothing caches the key across requests, rotating the one key is a configuration change, not a deployment event. ## Where to go from here The pattern itself is a few small functions per framework. To act on this understanding: ================================================================================ SOURCE: https://docs.sent.dm/llms/build/project-setup.txt TITLE: Setting Up the Project: SDK, Config, and Folder Layout ================================================================================ URL: https://docs.sent.dm/llms/build/project-setup.txt Prerequisites, SDK installation, the SDK-vs-raw-REST decision, config validation that fails fast, and a folder layout that realizes the reference architecture. # Setting Up the Project: SDK, Config, and Folder Layout You've seen the [shape of the integration](/build/architecture). Now stand up a project that realizes it: install the SDK, wire up config that fails fast on startup, and lay out the layers so "call the SDK" and "receive a webhook" never bleed into each other. Everything here is framework-agnostic. Pick your language in the tabs; the pattern is the same. ## Prerequisites Before any code, you need three things: **A Sent account.** Sign up and complete onboarding. If you're brand new, run the [Quickstart](/start/quickstart) end-to-end first. This guide assumes you've sent at least one message. **An API key.** Generate one on the [API Keys page in your Sent dashboard](https://app.sent.dm/dashboard/api-keys). Keep it handy, but note that it does **not** go into an env var or a config file. It's a per-request credential, supplied as `Authorization: Bearer `, never read once into a boot-time global or singleton client. Every chapter that follows builds on this convention; it's covered in full in [Authentication](/build/authentication). **At least one approved template.** Sent is template-first: outbound messages reference a template by `id` or `name`. Create and get one approved so you have something to send. See [Templates](/start/concepts/templates). ## Install the SDK Add the official Sent SDK for your language: ```bash npm install @sentdm/sentdm # or: pnpm add @sentdm/sentdm · yarn add @sentdm/sentdm ``` ```bash pip install sentdm # or: uv add sentdm ``` The SDK exposes both a sync `Sent` and an async `AsyncSent` client. ```bash go get github.com/sentdm/sent-dm-go ``` ```xml dm.sent sent-java ``` ```bash dotnet add package Sentdm ``` ```bash composer require sentdm/sent-dm-php ``` ```bash bundle add sentdm # or: gem install sentdm ``` See the [SDK reference](/sdks) for the full method surface in each language. ## SDK or raw v3 REST? Almost every integration should use the SDK. It handles auth headers, retries, the response envelope, and gives you typed methods. Reach for the raw v3 REST API only when you have a specific reason: | Use the **SDK** when… | Use **raw v3 REST** when… | |---|---| | You want typed methods, built-in retries, and less boilerplate. | You're on a runtime with no SDK, or want zero dependencies. | | You're integrating a supported language (all seven in the preceding section). | You need to see exactly what goes over the wire (learning, debugging). | | You want the maintained happy path. | You need a request shape the SDK doesn't expose yet. | The raw path looks like a small typed `fetch` client that sends the key as an `x-api-key` header and unwraps the v3 envelope `{ success, data, error, meta }`. ```ts import SentDm from "@sentdm/sentdm"; // One typed client, per request, bound to the caller's key. const client = new SentDm({ apiKey }); const res = await client.messages.send({ to, template }); ``` ```ts // A hand-rolled client: you own the transport and the envelope. export class SentV3Client { constructor(private opts: { apiKey: string; baseUrl: string }) {} async request(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${this.opts.baseUrl}${path}`, { method, headers: { "x-api-key": this.opts.apiKey, "content-type": "application/json", accept: "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); const parsed = JSON.parse(await res.text()); if (!res.ok || !parsed?.success) { throw new SentV3ApiError(parsed?.error?.message ?? res.statusText, res.status); } return parsed.data as T; // unwrap the { success, data, error, meta } envelope } } ``` Both the SDK and the raw v3 client send the key to Sent as `x-api-key`; that header name is internal to how you talk to Sent, separate from whatever scheme your own API uses to receive the key from its callers. ## Validate config at startup: fail fast Parse and validate your configuration at boot, and exit immediately if it's wrong. Nothing downstream should have to defend against missing config. But be precise about **what's in config**. Config holds operational knobs: base URL, log level, rate-limit settings, port. It does **not** hold the API key (that's per-request) and it does **not** *require* a webhook signing secret (that's a runtime value born when a customer registers a webhook; see [Endpoint management](/build/endpoint-management)). A `SENT_DM_WEBHOOK_SECRET` may appear only as an optional local/curl convenience, never as a required boot variable. ```ts import { z } from "zod"; const envSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("development"), PORT: z.string().transform(Number).default("3001"), // Operational knobs only. NOT the API key (per-request), and the webhook // secret is a runtime value — optional here, not required at boot. SENT_DM_WEBHOOK_SECRET: z.string().min(1).optional(), LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error", "fatal"]).default("info"), RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default("900000"), RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default("100"), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error("Invalid environment variables:", parsed.error.format()); process.exit(1); // fail fast } export const env = parsed.data; ``` ```python from functools import lru_cache from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") debug: bool = Field(default=False, alias="DEBUG") # Optional runtime convenience only — not required at boot. sent_dm_webhook_secret: str | None = Field(default=None, alias="SENT_DM_WEBHOOK_SECRET") sent_base_url: str | None = Field(default=None, alias="SENT_BASE_URL") rate_limit: str = Field(default="100/minute", alias="RATE_LIMIT") log_level: str = Field(default="INFO", alias="LOG_LEVEL") @lru_cache def get_settings() -> Settings: return Settings() # pydantic raises on invalid config at first access ``` ```go // Loaded with envconfig (kelseyhightower/envconfig); viper works the same way. type Config struct { Server ServerConfig Sent SentConfig RateLimit RateLimitConfig } type ServerConfig struct { Port string `envconfig:"PORT" default:"3031"` Environment string `envconfig:"ENVIRONMENT" default:"development"` } // Per-customer RUNTIME value, optional env fallback for dev/curl. Never // required to boot the server. type SentConfig struct { WebhookSecret string `envconfig:"SENT_DM_WEBHOOK_SECRET"` } type RateLimitConfig struct { RequestsPerSecond float64 `envconfig:"RATE_LIMIT_RPS" default:"10"` BurstSize int `envconfig:"RATE_LIMIT_BURST" default:"20"` } func Load() (*Config, error) { var cfg Config if err := envconfig.Process("", &cfg); err != nil { return nil, fmt.Errorf("failed to load config: %w", err) // fail fast } return &cfg, nil } ``` ```php // config/sent.php — operational knobs only, sourced from env at boot. return [ 'base_url' => env('SENT_BASE_URL', 'https://api.sent.dm'), // Optional runtime convenience only — never required to boot. 'webhook_secret' => env('SENT_DM_WEBHOOK_SECRET'), 'rate_limit' => env('RATE_LIMIT', '100,1'), // "max,per-minute" ]; ``` ```php Promise, ): RequestHandler { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } ``` `validateBody` checks `req.body` against a Zod schema and rejects invalid input with a `400` before the handler runs. It rethrows as the integration's single `ApiError` type, defined in [Errors & resilience](/build/errors-and-resilience#one-internal-error-type): ```ts // middleware/validate.ts import type { RequestHandler } from "express"; import { ZodError, type ZodSchema } from "zod"; import { ApiError } from "../types"; export function validateBody(schema: ZodSchema): RequestHandler { return (req, _res, next) => { try { req.body = schema.parse(req.body); next(); } catch (error) { if (error instanceof ZodError) { const details = error.errors.map((e) => ({ path: e.path.join("."), message: e.message })); return next(new ApiError(400, "ValidationError", "Request validation failed", { errors: details })); } next(error); } }; } ``` `webhookSecretStore` holds webhook signing secrets in memory as a set of candidates per endpoint id, so the receiver can verify whichever endpoint Sent delivers to and rotation never needs a coordinated swap (see [Endpoint management](/build/endpoint-management)): ```ts // webhooks/secret-store.ts /* * Production note: a real multi-instance service would load the secret per * delivery from its own datastore (keyed by X-Webhook-ID / endpoint), not from * process memory. */ const secretsByEndpoint = new Map>(); export const webhookSecretStore = { /** Hold a signing secret for an endpoint — on create and on rotate. */ remember(endpointId: string, secret: string): void { const candidates = secretsByEndpoint.get(endpointId) ?? new Set(); candidates.add(secret); secretsByEndpoint.set(endpointId, candidates); }, /** Candidate secrets for the endpoint named by X-Webhook-ID. */ candidatesFor(endpointId: string): string[] { return [...(secretsByEndpoint.get(endpointId) ?? [])]; }, /** Retire an old secret once deliveries verify against the new one. */ retire(endpointId: string, secret: string): void { secretsByEndpoint.get(endpointId)?.delete(secret); }, }; ``` The Python and Go samples don't need the first two: FastAPI's Pydantic models and dependency injection, and Echo's `Bind` plus centralized error handling, cover the same ground natively. Their secret stores (`webhook_secret_store` in Python, `h.secrets` in Go) mirror `remember`/`candidatesFor`. ## Next steps The client factory is the heart of this layout, and it hinges on one idea that trips up most integrations. Get it right next. ================================================================================ SOURCE: https://docs.sent.dm/llms/build/scaling-and-deployment.txt TITLE: Scaling & Deploying the Integration ================================================================================ URL: https://docs.sent.dm/llms/build/scaling-and-deployment.txt Take a single-instance Sent integration to a horizontally scaled deployment: shared state stores, cross-instance idempotency, graceful shutdown, and probes. # Scaling & Deploying the Integration The reference backends run beautifully as a single process. To run **more than one instance** (for availability or throughput), there is exactly one change to make: the three pieces of in-process state have to move to shared storage. Everything else (per-request client, service layer, verifier) is already stateless. This page is about that move, plus the deployment mechanics that make horizontal scaling safe. Samples on this page are shown in TypeScript and Python; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). ## The three things that aren't stateless By design, the API key is per-request and the SDK client is disposable, so there's no connection state to share. Only three things live in process memory: | In-memory today | Where it lives | Why it must be shared | |---|---|---| | **Message-status store** | `store/message-store.ts` | The send path records a message on instance A; the delivery webhook may land on instance B. B must see A's record to advance it. | | **Held webhook secrets** | `webhooks/secret-store.ts` | Every instance that can receive a delivery must be able to verify it, so every instance needs the current secrets. | | **Idempotency / dedupe keys** | inbound in the receiver, outbound in the send path | Two instances can each see the same retried webhook or the same retried send and both think it's new if they're not checking the same store. See [Idempotency across instances](#idempotency-across-instances) below. | Run two instances behind a load balancer with these stores in memory and you get silent, intermittent bugs: `GET /api/messages/:id` returns "not found" for messages sent by the other instance, webhooks `401` on whichever instance never saw the registration, and a retried webhook or send gets double-processed because the instance that handles the retry never saw the original. It'll look flaky. It's just unshared state. ## Move the message store to Redis/DB The store is written against a small interface: record on send, advance forward-only on webhook, read by id. Keep that surface identical and swap the backing map for Redis or your database. The forward-only, idempotent logic doesn't change. The in-memory version (abridged): ```ts // store/message-store.ts — the surface to preserve export class MessageStore { record(input: { id: string; status?: string; /* … */ }): StoredMessage { /* … */ } updateStatus(id: string, next: string): StoredMessage | null { /* forward-only */ } get(id: string): StoredMessage | null { /* … */ } } ``` A Redis-backed implementation keeps the same methods. The one new requirement is that the read-rank-compare-write in `updateStatus` has to be **atomic**: two instances handling concurrent webhooks must not both read the old status, both decide their event is a valid forward move, and both write, each unaware of the other. A Lua script runs atomically inside Redis (Redis executes it as a single, uninterruptible operation), so it's the natural tool here: ```ts // The same RANK/TERMINAL rules from the in-memory version, expressed in Lua so // Redis evaluates read-compare-write as one atomic step, not three round trips. const FORWARD_ONLY_LUA = ` local RANK = { QUEUED=0, ROUTED=1, SENT=2, DELIVERED=3, FAILED=3, READ=4 } local TERMINAL = { READ=true, FAILED=true } local key, next_status, updated_at = KEYS[1], ARGV[1], ARGV[2] local current = redis.call('HGET', key, 'status') if not current then return nil end -- unknown id if TERMINAL[current] then return current end -- locked if RANK[next_status] == nil or RANK[next_status] <= RANK[current] then return current -- no regress / dup end redis.call('HSET', key, 'status', next_status, 'updatedAt', updated_at) return next_status `; // store/redis-message-store.ts — same interface, shared backing export class RedisMessageStore { constructor(private readonly redis: Redis) {} async record(input: { id: string; status?: string; /* … */ }) { const key = `msg:${input.id}`; await this.redis.hset(key, { status: input.status ?? 'QUEUED', createdAt: new Date().toISOString(), /* … */ }); await this.redis.expire(key, 60 * 60 * 24 * 7); // TTL, like the in-memory eviction } // Forward-only advance across instances — see FORWARD_ONLY_LUA above. async updateStatus(id: string, next: string) { return this.redis.eval(FORWARD_ONLY_LUA, 1, `msg:${id}`, next, new Date().toISOString()); } async get(id: string) { const h = await this.redis.hgetall(`msg:${id}`); return Object.keys(h).length ? h : null; } } ``` On a SQL store instead of Redis, the same atomicity comes from a single `UPDATE … WHERE rank < :next`. The database's own row lock does the job a Lua script does in Redis. Same rule, different tool. See [Status tracking](/build/status-tracking) for where `RANK`/`TERMINAL` originally come from. ## Make the signing secret readable by every instance The in-memory secret store ([a shared helper](/build/project-setup#shared-helpers)) already flags this in its own comment: ```ts // webhooks/secret-store.ts /* * Production note: a real multi-instance service would load the secret per * delivery from its own datastore (keyed by X-Webhook-ID / endpoint), not from * process memory. */ ``` When a customer registers or rotates an endpoint (`POST /api/webhooks`, `POST /api/webhooks/:id/rotate-secret`), persist the returned secret to shared storage instead of a process-local `Set`. The receiver then loads the current secrets for that endpoint per delivery. - Store secrets encrypted at rest, keyed by webhook config id (`X-Webhook-ID`). - **Never log them.** See [Security](/build/security). - On rotation, persist the new secret immediately. The `X-Webhook-Signature` header carries a single signature, and Sent signs only with the current secret; the old one is invalid the moment rotate returns. Keeping the old secret in the receiver's candidate set while the new one propagates is a harmless mitigation, not an overlap Sent honors. See [Endpoint management](/build/endpoint-management). ## Idempotency across instances Acknowledge-fast plus idempotent processing already handles retries on one instance. Across instances the dedupe check must hit a **shared** store. Otherwise the same event processed on A and B both look "new." ```ts // Derive the key from the payload (X-Webhook-ID is NOT per-event). const dedupeKey = `evt:${payload.message_id}:${payload.message_status}`; // SET NX = "claim it only if unseen." Atomic and cross-instance. const claimed = await redis.set(dedupeKey, '1', 'EX', 86_400, 'NX'); if (!claimed) return res.status(200).json({ received: true }); // already handled ``` ## Graceful shutdown On deploy or scale-down your platform sends `SIGTERM`. Stop accepting new connections, let in-flight requests finish, then exit, with a hard timeout so a stuck request can't hang the rollout. ```ts // src/server.ts const shutdown = (signal: string) => { logger.info({ signal }, 'Shutting down...'); server.close(() => { // stop accepting, drain in-flight logger.info('Server closed'); process.exit(0); }); setTimeout(() => { // hard cap so a stuck request can't block forever logger.error('Forced shutdown'); process.exit(1); }, 10_000); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); ``` ```python # ASGI servers (uvicorn/gunicorn) handle SIGTERM draining for you: they stop # accepting, wait for in-flight requests, then exit. Tune the grace period, and # use FastAPI's lifespan to release shared resources (Redis/DB pools) on shutdown. # uvicorn app.main:app --timeout-graceful-shutdown 10 ``` Set your container's `terminationGracePeriodSeconds` (or platform equivalent) **longer** than your app's hard-shutdown timeout, so the orchestrator lets the drain finish instead of `SIGKILL`-ing mid-request. ## Health, readiness, and liveness A load balancer and an orchestrator ask different questions. Expose distinct endpoints so a temporary dependency blip doesn't get your pod killed. ```ts // src/controllers/health.controller.ts router.get('/', (_req, res) => res.status(200).json({ status: 'ok', backend: 'typescript', framework: 'express', uptime: process.uptime(), // …timestamp, version elided // No persistent Sent connection to report — the client is per-request. services: { sentdm: 'ready' }, }), ); // /live is a pure process check — never touches a dependency. router.get('/live', (_req, res) => res.status(200).json({ alive: true })); // /ready actually checks the shared dependency. A timed-out or failed PING // means "don't route traffic here" — remove from rotation, don't restart. router.get('/ready', async (_req, res) => { try { await redis.ping(); res.status(200).json({ ready: true }); } catch { res.status(503).json({ ready: false }); } }); ``` ```python # src/app/routers/health.py @router.get("/health") async def health_check(): return {"status": "ok", "backend": "python", "framework": "fastapi"} @router.get("/health/live") async def liveness(): return {"status": "alive"} # pure process check — never touches a dependency @router.get("/health/ready") async def readiness(): try: await redis_client.ping() return {"status": "ready"} except Exception: return JSONResponse({"status": "not_ready"}, status_code=503) ``` | Endpoint | Answers | Consumer | On failure | |---|---|---|---| | `/health` (`/live`) | "Is the process alive?" | Orchestrator liveness probe | Restart the instance | | `/ready` | "Can it serve traffic?" (shared deps reachable) | Load balancer / readiness probe | Remove from rotation, don't restart | Keep the dependency check on `/ready` only, never on `/live`, as shown in the preceding snippets. Coupling liveness to a dependency causes cascading restarts when that dependency has a hiccup. The orchestrator kills and restarts every instance in a loop instead of just routing around the outage, which is exactly what `/ready` returning `503` already accomplishes on its own. ## Containerization notes - **Config comes from the environment, not the image** (the ["twelve-factor app"](https://12factor.net/config) convention). No `SENT_DM_API_KEY`. The key arrives per request. Config that *is* env-based (log level, rate-limit window, Redis/DB URL) comes from the environment; nothing secret goes into the image. - **Logs to stdout** as JSON; the platform collects them. See [Observability](/build/observability). - **One process per container**, scaled by replica count behind a load balancer. - **Honor `SIGTERM`** (see [Graceful shutdown](#graceful-shutdown)). Run as a non-root user; multi-stage build for a lean image. - **Probes** wired to `/live` and `/ready`. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/security.txt TITLE: Securing Your Sent Integration: Secrets, CORS, and PII ================================================================================ URL: https://docs.sent.dm/llms/build/security.txt Hardening a Sent integration: per-request credentials, HTTPS, security headers and CORS, edge validation, rate limiting, and no secrets or PII in logs. # Securing Your Sent Integration: Secrets, CORS, and PII A Sent integration touches two credentials, the caller's **API key** and the **webhook signing secret**, and exposes at least one public, state-mutating endpoint. Get the handling of those two secrets right, verify every inbound request, and validate everything at the edge. The rest is defense in depth. Samples on this page are shown in TypeScript and Python; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). Nothing here is Sent-specific magic. It's standard backend hygiene applied to the two secrets and the one public endpoint this integration adds. ## Secrets: hold them for the shortest possible time Two rules cover almost every mistake Sent sees. **The API key is a request credential, not config.** Build the SDK client per request from the incoming `Authorization: Bearer ` header, use it, and throw the client away. Never persist the key, never log it, never bake it into a boot-time singleton or a `SENT_DM_API_KEY` env var. If there's no bearer token, return `401`; see [Authentication](/build/authentication). (Queued/async work is the one scoped exception, with its own hygiene rules; see [Errors & resilience](/build/errors-and-resilience#retries-and-exponential-backoff).) **The webhook signing secret never leaves memory and never gets logged.** It enters the process the moment a customer registers or rotates an endpoint, lives in a store keyed for verification, and is used only to compute HMACs. It is never written to logs, error messages, or responses. The single most common leak is an over-eager logger. `logger.info(req.headers)` or `logger.debug(req.body)` on a webhook route will happily print the signing secret material, the raw signature, and recipient phone numbers. Log identifiers, not payloads. See [Observability](/build/observability). ## HTTPS only Sent delivers webhooks over HTTPS and expects your registered endpoint to be HTTPS. In production: - Terminate TLS at your load balancer or gateway; redirect any `http://` to `https://`. - Register only `https://` webhook URLs. A plaintext endpoint leaks the raw body (recipient numbers, message content) and the signature on the wire. - Set HSTS. `helmet()` (below) does this and a dozen other headers for you. ## Security headers and CORS Wire `helmet()` for sane default headers and a `cors` policy that is strict in production and permissive only for your local dev origins. ```ts // src/app.ts app.use(helmet()); app.use( cors({ origin: env.NODE_ENV === 'production' ? [/\.your-domain\.com$/] // allow-list your own front-ends; no wildcard : ['http://localhost:3000', 'http://localhost:5173'], credentials: true, }), ); ``` ```python # src/app/main.py settings = get_settings() allowed_origins = ( ["https://app.your-domain.com"] # allow-list your own front-ends; no wildcard if not settings.debug # production else ["http://localhost:3000", "http://localhost:5173"] ) app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) ``` CORS protects your **browser** clients, not your webhook receiver. Sent's servers don't send an `Origin` header and aren't subject to CORS at all; the receiver's protection is signature verification, not CORS. Never rely on an origin allow-list to secure `/webhooks/sent`. ## Validate at the edge Parse and validate every request body before it reaches your service layer, and reject with a `400` on failure. Use a schema validator (zod in TypeScript, pydantic in Python) so malformed input never becomes a malformed SDK call. ```ts import { z } from 'zod'; const SendSchema = z.object({ to: z.array(z.string().min(3)).min(1), channel: z.array(z.enum(['sms', 'whatsapp', 'rcs'])).optional(), template: z.object({ id: z.string().uuid().optional(), name: z.string().optional(), parameters: z.record(z.string()).optional(), }), sandbox: z.boolean().optional(), }); const parsed = SendSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: { code: 'ValidationError', message: 'Invalid request body' }, }); } ``` ```python from pydantic import BaseModel, Field class Template(BaseModel): id: str | None = None name: str | None = None parameters: dict[str, str] | None = None class SendRequest(BaseModel): to: list[str] = Field(min_length=1) channel: list[str] | None = None template: Template sandbox: bool | None = None # FastAPI validates the body against SendRequest and returns 422 on failure. ``` For the webhook receiver, validate the signature on the **raw body first**, then parse JSON. Parsing before verifying hands attacker-controlled input to your JSON parser and, worse, re-serializes the body so the bytes you sign no longer match what Sent signed. See [Signature verification](/build/signature-verification). ## Rate limiting Rate-limit your public surface: apply a global limiter to everything and a **stricter, separate limiter** to the webhook receiver: the receiver is a public URL and should tolerate Sent's legitimate burst while shedding abuse. ```ts // src/app.ts — global limiter const limiter = rateLimit({ windowMs: env.RATE_LIMIT_WINDOW_MS, max: env.RATE_LIMIT_MAX_REQUESTS, standardHeaders: true, legacyHeaders: false, handler: (_req, res) => res.status(429).json({ error: { code: 'RateLimitExceeded', message: 'Too many requests' }, }), }); app.use(limiter); // A tighter limiter just for the receiver. skipSuccessfulRequests means // legitimate, verified deliveries don't count toward the cap — only the // junk (unverified, malformed) does. const webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, skipSuccessfulRequests: true, }); app.use('/webhooks', webhookLimiter, webhooksRouter); ``` ```python # src/app/main.py — global limiter, one bucket per client from slowapi import Limiter from slowapi.middleware import SlowAPIMiddleware from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address, default_limits=["100/minute"]) app.state.limiter = limiter app.add_middleware(SlowAPIMiddleware) ``` ```python # src/app/routers/webhooks.py — stricter limit on the public receiver @router.post("/webhooks/sent") @limiter.limit("60/minute") async def receive_webhook(request: Request): ... ``` In a multi-instance deployment, an in-memory rate-limit counter is per-instance, so your effective limit is `max × instances`. For a hard global cap, back the limiter with Redis (`rate-limit-redis` for express-rate-limit). See [Scaling & deployment](/build/scaling-and-deployment). ## Never log secrets or PII Treat these as never-log values, everywhere: - The API key and any `Authorization` header. - The webhook signing secret and the `X-Webhook-Signature` header. - Raw webhook and request **bodies**. They contain recipient phone numbers and message content. Log identifiers and outcomes instead: request ID, `message_id`, event type, status code, duration. If you must log a phone number for support, mask it (`+1******7890`). ## Replay protection (recap) Signature verification proves a request is authentic; the timestamp window proves it's *fresh*. A captured, valid request can otherwise be replayed indefinitely. Enforce both: - Reject if `|now − X-Webhook-Timestamp| > 300s` (±5 minutes). - `X-Webhook-Signature` carries exactly **one** signature, computed with the endpoint's current secret. Rotation is a hard replace: the old secret is invalid immediately, so update the receiver's stored secret as soon as you rotate. A receiver still verifying with the old secret rejects every delivery, and 10 consecutive failures auto-disable the endpoint. - Verify against the **raw body** with a constant-time compare. Full implementation and the exact HMAC scheme live in [Signature verification](/build/signature-verification). ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/sending-messages.txt TITLE: Sending Messages with the Sent SDK: The Outbound Path ================================================================================ URL: https://docs.sent.dm/llms/build/sending-messages.txt A thin, testable service layer over messages.send: the request shape, multi-recipient response mapping, channels, sandbox sends, and where status comes from. # Sending Messages with the Sent SDK: The Outbound Path Sending is the first half of your integration: your app calls Sent, Sent hands back a set of recipients, and you record them. The whole outbound path is one SDK call (`messages.send`) wrapped in a thin service method and a controller that does nothing but validate, delegate, and map. Get that seam right and everything downstream (status tracking, error handling, tests) falls out cleanly. Before you send anything, understand the concepts: [sending messages](/start/guides/sending-messages), [channels](/start/concepts/channels), and [templates](/start/concepts/templates). Sent is **template-first**: you send an approved template with parameters, not free-form text. ## The request shape Every backend maps one canonical request to `messages.send`: ```json { "to": ["+14155550123"], "channel": ["sms"], "template": { "name": "order_update", "parameters": { "1": "Ada" } }, "sandbox": false } ``` - **`to`**: an array of E.164 numbers. One call can fan out to many recipients. - **`channel`**: optional array; `sms`, `whatsapp`, or `rcs`. Omit it and Sent picks the channel from the template. See [Channels](/start/concepts/channels). - **`template`**: reference an approved template by **`name`** (as here) or by **`id`** (one selector is enough), plus its **`parameters`**. See [Templates](/start/concepts/templates). - **`sandbox`**: `true` for a dry run that validates and returns a shaped response without actually delivering. Great for wiring up the path end-to-end before going live. ## The service method Put the SDK call behind one method. It takes your typed input, calls `messages.send`, and maps the snake_case SDK response into your own camelCase contract, nothing else. Controllers never see an SDK type; tests mock this one method. The critical mapping: the response carries **`data.recipients[]`**, and each recipient's **`message_id`** becomes your `id`. Also lift **`data.status`**, **`data.template_id`**, and **`data.template_name`** to the top level. ```ts // services/sent.service.ts async send(input: SentMessageInput): Promise { try { const response = await this.client.messages.send({ to: input.to, channel: input.channel, template: input.template, sandbox: input.sandbox, }); const data = response.data ?? {}; const recipients = data.recipients ?? []; return { status: data.status, templateId: data.template_id, templateName: data.template_name, recipients: recipients.map((r) => ({ id: r.message_id, // ← message_id → id to: r.to, channel: r.channel, body: r.body, })), }; } catch (error) { throw this.toApiError(error, 'Failed to send message'); } } ``` ```python # app/services/sent_service.py async def send_canonical(self, request: CanonicalSendRequest) -> CanonicalSendResponse: template: dict[str, object] = {"parameters": request.template.parameters} if request.template.id is not None: template["id"] = request.template.id if request.template.name is not None: template["name"] = request.template.name response = await self._client.messages.send( to=request.to, template=template, channel=request.channel, sandbox=request.sandbox, ) data = response.data recipients = data.recipients if data and data.recipients else [] return CanonicalSendResponse( status=getattr(data, "status", None), templateId=getattr(data, "template_id", None), templateName=getattr(data, "template_name", None), recipients=[ CanonicalRecipient( id=r.message_id, # ← message_id → id to=r.to, channel=r.channel, body=r.body, ) for r in recipients ], ) ``` ```go // internal/models/contract.go — map the SDK response to the contract shape. func NewSendMessageContractResponse(data sentdm.MessageSendResponseData) SendMessageContractResponse { recipients := make([]MessageRecipient, 0, len(data.Recipients)) for _, r := range data.Recipients { recipients = append(recipients, MessageRecipient{ ID: r.MessageID, // ← message_id → id To: r.To, Channel: r.Channel, Body: r.Body, }) } return SendMessageContractResponse{ Status: data.Status, TemplateID: data.TemplateID, TemplateName: data.TemplateName, Recipients: recipients, } } // internal/services/sentclient.go — the thin wrapper (note: Sandbox, not TestMode). func (c *SentClient) SendMessageRaw(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() return c.client.Messages.Send(ctx, params) } ``` ```java // service/impl/CanonicalMessageServiceImpl.java public CanonicalSendResponse send(CanonicalSendRequest request) { SentClient sentClient = clientProvider.client(); MessageSendResponse response = sentClient.messages().send(buildParams(request)); var data = response.data().orElseThrow(() -> new RuntimeException("Empty response data")); String status = data.status().orElse("QUEUED"); var recipients = data.recipients().orElse(List.of()).stream() .map(r -> new CanonicalSendResponse.Recipient( r.messageId().orElse(""), // ← message_id → id r.to().orElse(""), r.channel().orElse(null), r.body().orElse(null))) .toList(); return new CanonicalSendResponse(status, data.templateId().orElse(null), data.templateName().orElse(null), recipients); } ``` ```csharp // Services/SentDmService.cs public async Task SendCanonicalAsync( CanonicalSendRequest request, CancellationToken ct = default) { var parameters = new MessageSendParams { Template = new Sentdm.Models.Messages.Template { ID = request.Template.Id, Name = request.Template.Name, Parameters = request.Template.Parameters ?? new Dictionary() }, To = request.To.ToArray(), Channel = request.Channel is { Count: > 0 } ? request.Channel.ToArray() : null, Sandbox = request.Sandbox }; var result = await Client.Messages.Send(parameters, ct).ConfigureAwait(false); var data = result.Data; var recipients = (data?.Recipients ?? Array.Empty()) .Select(r => new CanonicalRecipient( r.MessageID ?? string.Empty, // ← message_id → id r.To ?? string.Empty, r.Channel ?? string.Empty, r.Body ?? string.Empty)) .ToList(); return new CanonicalSendResponse( data?.Status ?? string.Empty, data?.TemplateID, data?.TemplateName, recipients); } ``` ```php // app/Services/SentDM/SentDMService.php public function send(array $to, ?array $channel, array $template, bool $sandbox = false): array { $response = $this->client->messages->send( to: $to, template: [ 'id' => $template['id'] ?? null, 'name' => $template['name'] ?? null, 'parameters' => $template['parameters'] ?? [], ], channel: $channel, sandbox: $sandbox, ); $data = $response->data; $recipients = array_map(fn ($r) => [ 'id' => $r->messageID, // ← message_id → id 'to' => $r->to, 'channel' => $r->channel, 'body' => $r->body, ], $data->recipients ?? []); return [ 'status' => $data->status, 'templateId' => $data->templateID, 'templateName' => $data->templateName, 'recipients' => $recipients, ]; } ``` ```ruby # app/services/sent_dm/send_message_service.rb def call validate! response = client.messages.send_( to: recipients, # an array — one call can fan out to many recipients template: { id: template_id, name: template_name, parameters: variables }, channel: channels # the Ruby SDK parameter is `channel:` (an array) ) success( status: response.data.status, recipients: response.data.recipients.map do |r| { id: r.message_id, to: r.to, channel: r.channel, body: r.body } # ← message_id → id end ) rescue ApplicationService::ValidationError => e failure(:validation_error, e.message) rescue StandardError => e handle_api_error(e) end ``` Map at the edge, every time. The SDK speaks snake_case (`message_id`, `template_id`); your contract speaks camelCase (`id`, `templateId`). Never leak raw SDK types into controllers or clients. The mapping is what keeps your surface stable when the SDK changes. See the full table in the [reference architecture](/build/architecture). ## The controller The controller is deliberately thin: build the per-request client, validate the body, call the service, persist each recipient into the message store, and return the mapped response. That's it. No business logic. `validateBody` and `asyncHandler` are the [shared helpers](/build/project-setup#shared-helpers) from project setup. ```ts // controllers/messages.controller.ts router.post( '/', validateBody(SendCanonicalSchema), asyncHandler(async (req, res) => { const dto = req.body as SendCanonicalDto; const { sentService } = servicesForRequest(req); // client built from Bearer key const result = await sentService.send({ to: dto.to, channel: dto.channel, template: dto.template, sandbox: dto.sandbox, }); // Persist each recipient so the webhook receiver can advance its status later. for (const r of result.recipients) { if (r.id) { messageStore.record({ id: r.id, to: r.to, channel: r.channel, templateId: result.templateId, templateName: result.templateName, status: result.status, }); } } res.status(200).json(result); }), ); ``` Notice `servicesForRequest(req)`. The SDK client is built **per request** from the caller's `Authorization: Bearer` key, never a boot-time singleton or an env var. This is the single most important pattern in the whole guide; see [Authentication](/build/authentication). ## Channels Pick channels per send with the `channel` array, or omit it and let the template's channel decide. - **`sms`**: plain SMS. Universally reachable; strict on template content. - **`whatsapp`**: rich templates, buttons, media. Requires approved WhatsApp templates. - **`rcs`**: richer than SMS where carriers and handsets support it. Passing more than one channel lets Sent route across them. Read [Channels](/start/concepts/channels) for how routing and fallback work. ## Sandbox sends Set `sandbox: true` (the request field is named `Sandbox` in the Go/C# SDKs too) to exercise the entire path (validation, mapping, store, and your response shape) without delivering a real message or spending credit. Flip it off for production traffic. ```json { "to": ["+14155550123"], "template": { "name": "order_update" }, "sandbox": true } ``` ## What you get back, and what you don't A successful send returns immediately with a **non-terminal** status, typically `QUEUED`: ```json { "status": "QUEUED", "templateId": "…", "templateName": "order_update", "recipients": [{ "id": "…", "to": "+14155550123", "channel": "sms", "body": "…" }] } ``` A `200` from `messages.send` means Sent **accepted** the message, not that it was delivered. The final outcome (`SENT`, `DELIVERED`, `READ`, or `FAILED`) arrives **asynchronously over a webhook**, minutes later. That's why the controller records each recipient into the message store on the way out: so the inbound webhook can advance its status. Close that loop in [Status tracking](/build/status-tracking). The send-then-track flow, end to end: ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/signature-verification.txt TITLE: Verifying Webhook Signatures ================================================================================ URL: https://docs.sent.dm/llms/build/signature-verification.txt The exact Svix-style HMAC scheme Sent uses to sign webhooks. Decode the secret, sign id.timestamp.body, compare in constant time, enforce the replay window. # Verifying Webhook Signatures This is the most important page in this section. A webhook endpoint is a public URL that mutates your state. If you don't verify signatures correctly, anyone who discovers the URL can forge `delivered` and `failed` events. Verification is the security boundary, and it is not optional boilerplate. Sent signs webhooks **Svix-style**: the same HMAC scheme used by [Svix](https://www.svix.com), a widely used webhook-infrastructure provider, so libraries and knowledge built around Svix's convention transfer directly. The scheme is small and exact; get every detail right and nothing else matters. Get one wrong and either everything is rejected or everything is forged. **The SDKs do not verify inbound webhooks.** There is no `client.webhooks.verifySignature()`. It does not exist. The SDKs expose webhook *management* (create, list, rotate, toggle, test), but verification is hand-rolled per language against the scheme below. If a future SDK adds a verifier with this exact scheme, prefer it. Until then, this code is yours to own. For the product-level overview, see [Signature verification](/start/webhooks/signature-verification) in the concepts docs. This page is the implementation. **Quick path.** Copy your language's function from [The verifier, in seven languages](#the-verifier-in-seven-languages) verbatim, call it with the raw request body plus the `X-Webhook-ID` / `X-Webhook-Timestamp` / `X-Webhook-Signature` headers and your stored secret, and reject the request unless it returns valid, before any other processing. The scheme details below explain *why* the code is shaped this way, and the [rotation](#handling-rotation) and [mistakes](#five-mistakes-to-avoid) sections are worth a read once, but the verifier function itself is the part you need first. ## The scheme Three lines. Memorize them: ```text key = base64_decode( secret without the "whsec_" prefix ) signed_content = "{X-Webhook-ID}.{X-Webhook-Timestamp}.{raw_body}" expected = "v1," + base64( HMAC_SHA256(key, signed_content) ) ``` Then **constant-time compare** `expected` against the `X-Webhook-Signature` header, never `==`/`===`: a normal comparison's early exit leaks timing an attacker can use to forge a signature (see [Signature verification](/start/webhooks/signature-verification) in the concepts docs for the full rationale). Every piece matters: - **The secret is base64, not UTF-8.** Strip the `whsec_` prefix, then base64-decode the rest. The HMAC key is the *decoded bytes*. - **The MAC covers `id.timestamp.body`**, joined by literal dots, rather than the body alone. The id and timestamp are *inside* the signature. - **Sign the raw body**, the exact bytes Sent sent, before any JSON parse or re-serialization. - **The output is versioned**: `v1,` followed by the base64 of the digest. ## The verifier, in seven languages Same scheme, idiomatic per language. ```ts import { createHmac, timingSafeEqual } from "node:crypto"; const SIGNATURE_VERSION = "v1"; const TOLERANCE_SECONDS = 300; export function computeSignature( id: string, timestamp: string, rawBody: Buffer | string, secret: string, ): string { const keyMaterial = secret.startsWith("whsec_") ? secret.slice(6) : secret; const key = Buffer.from(keyMaterial, "base64"); // base64-decode the secret const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"); const signedContent = `${id}.${timestamp}.${body}`; // id.timestamp.body const digest = createHmac("sha256", key).update(signedContent, "utf8").digest("base64"); return `${SIGNATURE_VERSION},${digest}`; // v1, } function constantTimeEquals(a: string, b: string): boolean { const ab = Buffer.from(a), bb = Buffer.from(b); if (ab.length !== bb.length) return false; return timingSafeEqual(ab, bb); } export function verifyWebhook(input: { id?: string; timestamp?: string; signature?: string; rawBody: Buffer | string; secret: string; }): { valid: true } | { valid: false; reason: string } { const { id, timestamp, signature, rawBody, secret } = input; if (!id || !timestamp || !signature) return { valid: false, reason: "missing signature headers" }; if (!secret) return { valid: false, reason: "webhook secret not configured" }; const ts = Number(timestamp); if (!Number.isFinite(ts)) return { valid: false, reason: "invalid timestamp" }; const now = Math.floor(Date.now() / 1000); if (Math.abs(now - ts) > TOLERANCE_SECONDS) return { valid: false, reason: "timestamp outside tolerance window" }; const expected = computeSignature(id, timestamp, rawBody, secret); // Sent sends exactly one signature; the split is future-proofing, not rotation handling. const matched = signature.split(" ").some((c) => constantTimeEquals(c.trim(), expected)); return matched ? { valid: true } : { valid: false, reason: "signature mismatch" }; } ``` ```python import base64, hashlib, hmac, time SIGNATURE_VERSION = "v1" DEFAULT_TOLERANCE_SECONDS = 300 def compute_signature(webhook_id: str, timestamp: str, raw_body: bytes, secret: str) -> str: key_material = secret[len("whsec_"):] if secret.startswith("whsec_") else secret key = base64.b64decode(key_material) # base64-decode the secret signed_content = b"%s.%s.%s" % ( # id.timestamp.body (bytes) webhook_id.encode(), timestamp.encode(), raw_body, ) digest = hmac.new(key, signed_content, hashlib.sha256).digest() return f"{SIGNATURE_VERSION},{base64.b64encode(digest).decode('ascii')}" def verify_webhook(*, webhook_id, timestamp, signature, raw_body, secret, tolerance_seconds=DEFAULT_TOLERANCE_SECONDS): if not webhook_id or not timestamp or not signature: return False, "missing signature headers" if not secret: return False, "webhook secret not configured" try: ts = int(timestamp) except (TypeError, ValueError): return False, "invalid timestamp" if abs(int(time.time()) - ts) > tolerance_seconds: return False, "timestamp outside tolerance window" expected = compute_signature(webhook_id, timestamp, raw_body, secret) # Sent sends exactly one signature; the split is future-proofing (constant-time compare). matched = any( hmac.compare_digest(candidate.strip(), expected) for candidate in signature.split(" ") if candidate.strip() ) return (True, None) if matched else (False, "signature mismatch") ``` ```go import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "strconv" "strings" "time" ) const ToleranceSeconds = 300 func Verify(id, timestamp, signature string, rawBody []byte, secret string) (bool, string) { if secret == "" { return false, "webhook secret not configured" } if id == "" || timestamp == "" || signature == "" { return false, "missing signature headers" } ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false, "invalid timestamp" } if diff := time.Now().Unix() - ts; diff > ToleranceSeconds || diff < -ToleranceSeconds { return false, "timestamp outside tolerance window" } expected, err := computeSignature(id, timestamp, rawBody, secret) if err != nil { return false, "invalid secret encoding" } // Sent sends exactly one signature; the split is future-proofing. for _, candidate := range strings.Fields(signature) { if hmac.Equal([]byte(candidate), []byte(expected)) { // constant-time return true, "" } } return false, "signature mismatch" } func computeSignature(id, timestamp string, body []byte, secret string) (string, error) { key := strings.TrimPrefix(secret, "whsec_") // Secrets are standard, padded base64 (32 random bytes), so StdEncoding is // correct here -- not the URL-safe or raw (unpadded) alphabet. keyBytes, err := base64.StdEncoding.DecodeString(key) // base64-decode the secret if err != nil { return "", err } mac := hmac.New(sha256.New, keyBytes) mac.Write([]byte(id + "." + timestamp + ".")) // id.timestamp. mac.Write(body) // + raw body return "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil } ``` ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Base64; public class WebhookSignatureVerifier { private static final String SIGNATURE_VERSION = "v1"; private static final String HMAC_ALGORITHM = "HmacSHA256"; private static final long TOLERANCE_SECONDS = 300; public enum Result { VALID, MISSING_HEADERS, INVALID } public Result verify(String id, String timestamp, String signature, String rawBody, String secret) { return verify(id, timestamp, signature, rawBody, secret, System.currentTimeMillis() / 1000); } Result verify(String id, String timestamp, String signature, String rawBody, String secret, long nowSeconds) { if (isBlank(id) || isBlank(timestamp) || isBlank(signature)) return Result.MISSING_HEADERS; if (isBlank(secret)) return Result.INVALID; // fail closed final long ts; try { ts = Long.parseLong(timestamp.trim()); } catch (NumberFormatException e) { return Result.INVALID; } if (Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return Result.INVALID; final String expected; try { expected = computeSignature(secret, id, timestamp.trim(), rawBody); } catch (Exception e) { return Result.INVALID; } // Sent sends exactly one signature; the split is future-proofing (constant-time compare). byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); for (String candidate : signature.trim().split(" ")) { if (candidate.isEmpty()) continue; if (MessageDigest.isEqual(candidate.trim().getBytes(StandardCharsets.UTF_8), expectedBytes)) return Result.VALID; } return Result.INVALID; } private String computeSignature(String secret, String id, String timestamp, String rawBody) throws Exception { String keyMaterial = secret.startsWith("whsec_") ? secret.substring(6) : secret; byte[] key = Base64.getDecoder().decode(keyMaterial); // base64-decode the secret String signedContent = id + "." + timestamp + "." + rawBody; // id.timestamp.body Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(key, HMAC_ALGORITHM)); byte[] digest = mac.doFinal(signedContent.getBytes(StandardCharsets.UTF_8)); return SIGNATURE_VERSION + "," + Base64.getEncoder().encodeToString(digest); } private static boolean isBlank(String s) { return s == null || s.isBlank(); } } ``` ```csharp using System.Security.Cryptography; using System.Text; public class WebhookSignatureVerifier { public const int ToleranceSeconds = 300; public readonly record struct Result(bool Valid, string Reason) { public static Result Ok() => new(true, string.Empty); public static Result Fail(string reason) => new(false, reason); } public Result Verify(string? id, string? timestamp, string? signature, byte[] rawBody, string? secret) { if (string.IsNullOrEmpty(secret)) return Result.Fail("webhook secret not configured"); if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature)) return Result.Fail("missing signature headers"); if (!long.TryParse(timestamp, out var ts)) return Result.Fail("invalid timestamp"); var diff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts; if (diff > ToleranceSeconds || diff < -ToleranceSeconds) return Result.Fail("timestamp outside tolerance window"); if (!TryComputeSignature(id, timestamp, rawBody, secret, out var expected)) return Result.Fail("invalid secret encoding"); var expectedBytes = Encoding.UTF8.GetBytes(expected); // Sent sends exactly one signature; the split is future-proofing. foreach (var candidate in signature.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate), expectedBytes)) return Result.Ok(); } return Result.Fail("signature mismatch"); } private static bool TryComputeSignature(string id, string timestamp, byte[] body, string secret, out string signature) { signature = string.Empty; var key = secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret["whsec_".Length..] : secret; byte[] keyBytes; try { keyBytes = Convert.FromBase64String(key); } // base64-decode the secret catch (FormatException) { return false; } var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}."); // id.timestamp. var signedContent = new byte[prefix.Length + body.Length]; Buffer.BlockCopy(prefix, 0, signedContent, 0, prefix.Length); Buffer.BlockCopy(body, 0, signedContent, prefix.Length, body.Length); // + raw body bytes using var hmac = new HMACSHA256(keyBytes); signature = "v1," + Convert.ToBase64String(hmac.ComputeHash(signedContent)); return true; } } ``` ```php secret)) return false; if ($webhookId === '' || $timestamp === '' || $signature === '') return false; if (!is_numeric($timestamp) || abs(time() - (int) $timestamp) > self::TOLERANCE_SECONDS) return false; $expected = $this->expectedSignature($webhookId, $timestamp, $rawBody); if ($expected === null) return false; // Sent sends exactly one signature; the split is future-proofing (constant-time compare). foreach (preg_split('/\s+/', trim($signature)) as $candidate) { if ($candidate !== '' && hash_equals($expected, $candidate)) return true; } return false; } private function expectedSignature(string $webhookId, string $timestamp, string $rawBody): ?string { $key = $this->secret; if (str_starts_with($key, 'whsec_')) $key = substr($key, strlen('whsec_')); $keyBytes = base64_decode($key, true); // base64-decode the secret if ($keyBytes === false) return null; $signed = "{$webhookId}.{$timestamp}.{$rawBody}"; // id.timestamp.body return 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true)); } } ``` ```ruby require "base64" require "openssl" module SentDm class SignatureVerifier SIGNATURE_VERSION = "v1".freeze TOLERANCE_SECONDS = 300 def initialize(id:, timestamp:, signature:, raw_body:, secret:, now: Time.now.to_i) @id, @timestamp, @signature = id, timestamp, signature @raw_body, @secret, @now = raw_body, secret, now end def self.verify(...) = new(...).verify def verify return [false, "missing signature headers"] if @id.to_s.empty? || @timestamp.to_s.empty? || @signature.to_s.empty? return [false, "webhook secret not configured"] if @secret.to_s.empty? ts = Integer(@timestamp, exception: false) return [false, "invalid timestamp"] if ts.nil? return [false, "timestamp outside tolerance window"] if (@now - ts).abs > TOLERANCE_SECONDS # Sign the raw header string, not the parsed integer: Integer() strips # surrounding whitespace, so a padded header would sign different bytes # than Sent did. `ts` is only used for the tolerance check above. expected = expected_signature(@timestamp) # Sent sends exactly one signature; the split is future-proofing (constant-time compare). matched = @signature.split(" ").any? { |c| secure_compare(c.strip, expected) } matched ? [true, nil] : [false, "signature mismatch"] end private def expected_signature(timestamp) # `timestamp` is the raw header value (a String), matching what Sent signed. key = Base64.decode64(@secret.to_s.delete_prefix("whsec_")) # base64-decode the secret signed = "#{@id}.#{timestamp}.#{@raw_body}" # id.timestamp.body digest = OpenSSL::HMAC.digest("sha256", key, signed) "#{SIGNATURE_VERSION},#{Base64.strict_encode64(digest)}" end def secure_compare(a, b) # Plain Ruby + openssl (already required above) — works outside Rails too, # unlike ActiveSupport::SecurityUtils.secure_compare. OpenSSL.fixed_length_secure_compare(a, b) rescue ArgumentError false # differing lengths — not a match, and not constant-time either way end end end ``` ## The replay window A valid signature on a captured request is still an attack if it's replayed hours later. So reject any event whose timestamp is more than **300 seconds (5 minutes)** away from now, in either direction: ```text reject if abs(now - X-Webhook-Timestamp) > 300 ``` Because the timestamp is part of the signed content, an attacker can't rewrite it to a fresh value without invalidating the signature. The window and the signature reinforce each other. ## Handling rotation Rotating a secret replaces it in one step: the old secret is invalid the moment the rotate call returns, and every delivery from then on is signed **only** with the new secret. Sent never dual-signs: `X-Webhook-Signature` always carries exactly one `v1,…` token. What makes rotation safe lives on your side: hold a *set* of candidate secrets per endpoint and accept a delivery if **any** of them verifies. Add the new secret to the set the moment rotate returns; the old entry is harmless (it never matches again), so you can retire it on your own schedule. Deliveries that arrive before the new secret reaches your store fail verification and are retried, so keep that gap short. The verifiers in the preceding section also split the header on spaces and test each token. Sent currently sends exactly one signature, so the split is defensive, not load-bearing. Pair this page with [Endpoint management](/build/endpoint-management), where the store persists the new secret as soon as it's issued. ## Five mistakes to avoid **1. Not decoding the secret.** The most common bug. `HMAC(secret_string, body)` (using the literal `whsec_...` string as the key) will never match. Strip the prefix and *base64-decode* the rest; the key is the decoded bytes. **2. Signing only the body.** The MAC covers `{id}.{timestamp}.{body}`, joined by literal dots. Signing just the body drops the id and timestamp from the signature and breaks cross-endpoint and replay protection. **3. Ignoring the replay window.** A signature verifies forever unless you bound it in time. Without the ±300s check, a captured-and-replayed request sails through. Enforce it. **4. Comparing with `==`.** A plain equality check leaks timing information. Always use a constant-time compare: `timingSafeEqual` / `hmac.compare_digest` / `hmac.Equal` / `MessageDigest.isEqual` / `FixedTimeEquals` / `hash_equals` / `secure_compare`. **5. Verifying parsed-then-reserialized JSON.** Sign the exact raw bytes Sent sent; a re-serialized body rarely matches them (covered in [The webhook receiver](/build/webhook-receiver)). ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/status-tracking.txt TITLE: Tracking Delivery Status ================================================================================ URL: https://docs.sent.dm/llms/build/status-tracking.txt Turn the stream of webhook events into a queryable per-message delivery status with a forward-only, idempotent store exposed on your own messages endpoint. # Tracking Delivery Status You send a message and get back a `QUEUED` acknowledgement. Its *real* outcome (delivered, read, or failed) arrives later, asynchronously, over the webhooks you built with the [receiver](/build/webhook-receiver), [signature verification](/build/signature-verification), and [endpoint management](/build/endpoint-management). This page closes the loop: how to fold that event stream into a single, queryable status per message. The pattern is small and the same in every language: **record on send, advance forward-only on each webhook, expose a read endpoint.** Samples on this page are shown in TypeScript, Python, and Go; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). For the product-level view of what each status means, see [Message status tracking](/start/guides/message-status-tracking) and the [event types](/start/webhooks/event-types) reference. ## The status model Statuses form a strict progression. A message ranks up as it advances and never goes backwards. This is what makes out-of-order and duplicate deliveries safe. | Status | Rank | Meaning | |---|---|---| | `QUEUED` | 0 | Accepted by Sent, not yet routed. | | `ROUTED` | 1 | Handed to a carrier / channel. | | `SENT` | 2 | Left Sent toward the recipient. | | `DELIVERED` | 3 | Confirmed delivered. | | `FAILED` | 3 | Terminal failure (shares the rank of `DELIVERED`). | | `READ` | 4 | Recipient read it, the only state ranked higher than `DELIVERED`. | Two rules fall out of the ranks: - **Forward-only.** Apply a new status only if its rank is *higher* than the current one. A late `SENT` webhook arriving after `DELIVERED` is ignored. - **Terminal is terminal.** `READ` and `FAILED` are locked; nothing supersedes them. A `DELIVERED` and a `FAILED` can't overwrite each other. They share a rank, so neither wins a race. Events can arrive out of order. Sent delivers *at least once* and over an unordered network, so you may see `DELIVERED` before `SENT`. Ranking, not arrival order, decides the stored status. ## Idempotency and dedupe Because delivery is at-least-once, the same event can arrive twice. Two layers protect you: 1. **Dedupe on a per-event key** at the receiver: `message_id` + `message_status`, a no-op before processing even starts (built in [The webhook receiver](/build/webhook-receiver)). Don't key off `X-Webhook-ID`: that's the *endpoint* id, identical on every delivery. 2. **Forward-only updates** in the store: even if a duplicate slips through, re-applying the same or a lower-ranked status changes nothing. This is your real safety net. Together they make the whole pipeline safe to replay. ## The message store The store records a message when you send it, then `updateStatus` advances it as webhooks arrive. `updateStatus` is the heart of it: it's a no-op if the id is unknown, if the message is terminal, or if the incoming rank isn't higher. ```ts const RANK: Record = { QUEUED: 0, ROUTED: 1, SENT: 2, DELIVERED: 3, FAILED: 3, READ: 4, }; const TERMINAL = new Set(["READ", "FAILED"]); updateStatus(id: string, next: string): StoredMessage | null { const record = this.map.get(id); if (!record) return null; // unknown id const status = normalize(next); if (!status) return record; if (TERMINAL.has(record.status)) return record; // locked if (rank(status) <= rank(record.status)) return record; // no regress / dupe record.status = status; record.updatedAt = new Date().toISOString(); record.history.push({ status, at: record.updatedAt }); return record; } ``` ```python RANK = {"QUEUED": 0, "ROUTED": 1, "SENT": 2, "DELIVERED": 3, "FAILED": 3, "READ": 4} TERMINAL = {"READ", "FAILED"} def update_status(self, id: str, next_status: str | None) -> dict | None: with self._lock: record = self._map.get(id) if record is None: return None # unknown id status = _normalize(next_status) if not status: return dict(record) if record["status"] in TERMINAL: return dict(record) # locked if _rank(status) <= _rank(record["status"]): return dict(record) # no regress / dupe now = _now() record["status"] = status record["updatedAt"] = now record["history"].append({"status": status, "at": now}) return dict(record) ``` ```go var rank = map[string]int{ "QUEUED": 0, "ROUTED": 1, "SENT": 2, "DELIVERED": 3, "FAILED": 3, "READ": 4, } var terminal = map[string]bool{"READ": true, "FAILED": true} func (s *MessageStore) UpdateStatus(id, next string) (*StoredMessage, bool) { s.mu.Lock() defer s.mu.Unlock() rec, exists := s.messages[id] if !exists { return nil, false // unknown id } status, ok := normalize(next) if !ok { return rec, true } if terminal[rec.Status] { return rec, true // locked } if rank[status] <= rank[rec.Status] { return rec, true // no regress / dupe } now := time.Now().UTC().Format(time.RFC3339) rec.Status = status rec.UpdatedAt = now rec.History = append(rec.History, StatusEvent{Status: status, At: now}) return rec, true } ``` ## Mapping events to statuses The webhook payload usually carries `message_status` directly. When it doesn't, derive the status from the event type. The two agree, so either source works: ```ts const STATUS_BY_EVENT: Record = { "message.queued": "QUEUED", "message.routed": "ROUTED", "message.sent": "SENT", "message.delivered": "DELIVERED", "message.read": "READ", "message.failed": "FAILED", }; // In the processor, after verification: const nextStatus = payload.message_status ?? STATUS_BY_EVENT[eventType]; if (payload.message_id && nextStatus) { messageStore.updateStatus(payload.message_id, nextStatus); } ``` ```python STATUS_BY_EVENT = { "message.queued": "QUEUED", "message.routed": "ROUTED", "message.sent": "SENT", "message.delivered": "DELIVERED", "message.read": "READ", "message.failed": "FAILED", } # In the processor, after verification: next_status = payload.get("message_status") or STATUS_BY_EVENT.get(event_type) if message_id and next_status: message_store.update_status(message_id, next_status) ``` ```go var statusByEvent = map[string]string{ "message.queued": "QUEUED", "message.routed": "ROUTED", "message.sent": "SENT", "message.delivered": "DELIVERED", "message.read": "READ", "message.failed": "FAILED", } // In the processor, after verification: next := payload.MessageStatus if next == "" { next = statusByEvent[eventType] } if payload.MessageID != "" && next != "" { store.Default.UpdateStatus(payload.MessageID, next) } ``` `message.received` (an inbound reply) has no `message_id` of yours to advance. It's a new inbound message, not a status update. Route it to your reply-handling logic instead. See [event types](/start/webhooks/event-types). ## Exposing the status Record the message on the send path so there's something to advance, then serve its current state at `GET /api/messages/:id`: If you're multi-tenant, requiring *a* valid key isn't enough. A message id alone must never be enough to read someone else's status. Record a **tenant identifier** (a hash of the API key, or your own customer id resolved from it, never the raw key itself) alongside the message, and check it on every read, not just on write. The examples below do this with a SHA-256 hash of the key; single-tenant apps can skip the tenant check entirely, since there's only one tenant. ```ts import { createHash } from "node:crypto"; const tenantIdFor = (apiKey: string) => createHash("sha256").update(apiKey).digest("hex"); // On send: record so webhooks have a row to advance. const tenantId = tenantIdFor(apiKey); // the key resolved by servicesForRequest, see Authentication for (const r of result.recipients) { messageStore.record({ id: r.id, tenantId, to: r.to, channel: r.channel, templateId: result.templateId, templateName: result.templateName, status: result.status, }); } // Read endpoint: a valid key is necessary but not sufficient — the message's // tenantId must match the caller's, or this returns 404 (never 403, so a // wrong-tenant read looks identical to "doesn't exist"). router.get("/:id", (req, res) => { const apiKey = apiKeyFromRequest(req); if (!apiKey) { throw new ApiError(401, "Unauthorized", "Missing API key — send it as Authorization: Bearer ."); } const message = messageStore.get(req.params.id); if (!message || message.tenantId !== tenantIdFor(apiKey)) { return res.status(404).json({ error: "not found" }); } res.json(message); }); ``` ```python import hashlib def tenant_id_for(api_key: str) -> str: return hashlib.sha256(api_key.encode()).hexdigest() # On send: record so webhooks have a row to advance. tenant_id = tenant_id_for(api_key) # the key resolved by get_sent_client, see Authentication for r in result.recipients: message_store.record( id=r.id, tenant_id=tenant_id, to=r.to, channel=r.channel, template_id=result.template_id, template_name=result.template_name, status=result.status, ) # Read endpoint: a valid key is necessary but not sufficient — the message's # tenant_id must match the caller's, or this returns 404 (never 403, so a # wrong-tenant read looks identical to "doesn't exist"). @router.get("/api/messages/{message_id}") async def get_message( message_id: str, token: str | None = Depends(get_optional_token), ): api_key = _resolve_api_key(token) # 401s if missing; see Authentication message = message_store.get(message_id) if message is None or message.tenant_id != tenant_id_for(api_key): raise HTTPException(status_code=404, detail="not found") return message ``` ```go func tenantIDFor(apiKey string) string { sum := sha256.Sum256([]byte(apiKey)) return hex.EncodeToString(sum[:]) } // On send: record so webhooks have a row to advance. tenantID := tenantIDFor(apiKey) // the key resolved by ClientResolver, see Authentication for _, r := range result.Recipients { store.Default.Record(store.RecordInput{ ID: r.ID, TenantID: tenantID, To: r.To, Channel: r.Channel, TemplateID: result.TemplateID, TemplateName: result.TemplateName, Status: result.Status, }) } // Read endpoint: a valid key is necessary but not sufficient — the message's // TenantID must match the caller's, or this returns 404 (never 403, so a // wrong-tenant read looks identical to "doesn't exist"). e.GET("/api/messages/:id", func(c echo.Context) error { apiKey := resolver.APIKeyFromRequest(c) if apiKey == "" { return ErrMissingAPIKey // 401; see Authentication } msg, ok := store.Default.Get(c.Param("id")) if !ok || msg.TenantID != tenantIDFor(apiKey) { return c.JSON(http.StatusNotFound, echo.Map{"error": "not found"}) } return c.JSON(http.StatusOK, msg) }) ``` Each record keeps a `history` array too (a timestamped trail of every status it passed through), which is invaluable when debugging a delivery. ## Productionize the store Keeping this store in **process memory** is perfect for a single instance and for learning the pattern, but it's the wrong choice in production: it's lost on restart, and a second instance won't see the first's data. The send path and the webhook receiver may even land on different instances. The logic (record on send, forward-only advance on webhook, dedupe on the per-event key) is identical against a real datastore. Swap the in-memory map for Redis (with a TTL) or your primary database. The `updateStatus` rank check maps cleanly onto a conditional `UPDATE ... WHERE rank < :newRank`. [Scaling & deployment](/build/scaling-and-deployment) covers the swap. ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/testing.txt TITLE: Testing Your Sent Integration: Unit, Route, and Sandbox ================================================================================ URL: https://docs.sent.dm/llms/build/testing.txt Test a Sent integration at every layer: mock the SDK for unit tests, drive real routes over HTTP, send safely with sandbox, and pin the webhook verifier. # Testing Your Sent Integration: Unit, Route, and Sandbox The [layered architecture](/build/architecture) exists partly so this page is short. Because the SDK is reached only through the service layer, and credentials arrive per request rather than from global config, every layer is testable in isolation without real network calls or secrets. Four kinds of test cover the whole integration. Samples on this page are shown in TypeScript and Python; the patterns are framework-agnostic and transfer directly to the other [supported SDK languages](/sdks). ## 1 · Unit-test the service layer with a mocked SDK The service layer is the one place SDK types appear. Test it by injecting a **mock SDK client** and asserting two things: the SDK was called with the right shape, and the response was mapped into your contract (snake → camel) correctly. No network, no key. ```ts // services/sent.service.spec.ts const mockClient = { messages: { send: vi.fn() } }; const service = new SentService(mockClient as any, mockLogger as any); it('send maps the SDK response into the canonical shape', async () => { mockClient.messages.send.mockResolvedValue({ data: { status: 'QUEUED', template_id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', template_name: 'welcome', recipients: [{ message_id: 'msg_123', to: '+14155550123', channel: 'sms' }], }, }); const result = await service.send({ to: ['+14155550123'], channel: ['sms'], template: { name: 'welcome' }, }); // SDK snake_case → contract camelCase; message_id → id. expect(result).toEqual({ status: 'QUEUED', templateId: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', templateName: 'welcome', recipients: [{ id: 'msg_123', to: '+14155550123', channel: 'sms', body: undefined }], }); }); ``` ```python # tests/test_sent_service.py — pytest-asyncio import pytest from unittest.mock import AsyncMock, MagicMock from app.models.schemas import CanonicalSendRequest from app.services.sent_service import SentService @pytest.mark.asyncio async def test_send_canonical_maps_response(): data = MagicMock( status="QUEUED", template_id="7ba7b820-9dad-11d1-80b4-00c04fd430c8", template_name="welcome", recipients=[MagicMock(message_id="msg_123", to="+14155550123", channel="sms", body=None)], ) client = MagicMock() client.messages.send = AsyncMock(return_value=MagicMock(data=data)) service = SentService(client) result = await service.send_canonical(CanonicalSendRequest( to=["+14155550123"], channel=["sms"], template={"name": "welcome"}, )) # SDK snake_case → contract camelCase; message_id → id. assert result.status == "QUEUED" assert result.templateId == "7ba7b820-9dad-11d1-80b4-00c04fd430c8" assert result.recipients[0].id == "msg_123" ``` A **test container** (e.g. `tests/setup.ts`) that swaps the whole dependency container for mocks (a `sentService`/`messagesService` full of `vi.fn()` and a no-op logger) keeps this clean in TypeScript. Injecting dependencies rather than importing singletons is what makes this work; the same pattern applies with pytest fixtures or Spring's `@MockBean`. ## 2 · Integration-test your routes over HTTP Mount the real app with a mocked service container and drive it over HTTP. This exercises routing, edge validation, body parsing, and error mapping together: everything except the actual Sent call. ```ts // tests/messages.integration.spec.ts import request from 'supertest'; import { createApp } from '../app'; import { setupTestContainer } from './setup'; beforeEach(() => { setupTestContainer({ sentService: { send: vi.fn().mockResolvedValue({ status: 'QUEUED', templateId: '550e8400-e29b-41d4-a716-446655440000', templateName: 'welcome', recipients: [{ id: 'msg_123', to: '+14155550123', channel: 'sms' }], }), } as any, }); app = createApp(); }); it('POST /api/messages succeeds with a valid payload', async () => { const res = await request(app) .post('/api/messages') .set('Authorization', 'Bearer test-key') .send({ to: ['+14155550123'], channel: ['sms'], template: { name: 'welcome', parameters: { name: 'Ada' } }, }); expect(res.status).toBe(200); expect(res.body.recipients[0].id).toBe('msg_123'); }); it('POST /api/messages returns 400 for an invalid body', async () => { const res = await request(app) .post('/api/messages') .set('Authorization', 'Bearer test-key') .send({ template: { name: 'welcome' } }); // missing `to` expect(res.status).toBe(400); expect(res.body.error.code).toBe('ValidationError'); }); ``` ```python # tests/test_messages.py — FastAPI TestClient (see conftest.py fixtures) from app.models.schemas import CanonicalSendResponse, CanonicalRecipient async def fake_send_canonical(self, request): # Return the real response model — the route iterates result.recipients as objects. return CanonicalSendResponse( status="QUEUED", templateId="550e8400-e29b-41d4-a716-446655440000", templateName="welcome", recipients=[CanonicalRecipient(id="msg_123", to="+14155550123", channel="sms")], ) def test_send_succeeds(client, monkeypatch): # send_canonical is async — replace it with an async stub. monkeypatch.setattr( "app.services.sent_service.SentService.send_canonical", fake_send_canonical ) res = client.post( "/api/messages", headers={"Authorization": "Bearer test-key"}, json={ "to": ["+14155550123"], "channel": ["sms"], "template": {"name": "welcome", "parameters": {"name": "Ada"}}, }, ) assert res.status_code == 200 assert res.json()["recipients"][0]["id"] == "msg_123" def test_health(client): res = client.get("/health") assert res.json() == {"status": "ok", "backend": "python", "framework": "fastapi"} ``` Also assert the unhappy paths: a request with no `Authorization` header should `401`, a malformed body should `400`, and an unknown route should `404`. These are cheap to write and catch the mistakes that hurt most in production. ## 3 · Safe end-to-end sends with `sandbox: true` When you want a real round-trip against Sent without delivering to a real handset, pass `sandbox: true` on the send. The request goes through the full stack and returns a real response shape, but no message is actually delivered, ideal for CI smoke tests and staging. ```ts await sentService.send({ to: ['+1234567890'], channel: ['sms'], template: { name: 'welcome', parameters: { name: 'Test' } }, sandbox: true, // full round-trip, nothing delivered }); ``` `sandbox` is a per-send flag, not an environment mode. The same running service handles sandbox and live sends side by side. Make sure production traffic does **not** carry `sandbox: true`, and that any test harness that sets it can't leak into prod. Confirm it's off before go-live. See [Going to production](/build/going-to-production). ## 4 · Test the webhook verifier against a known vector The verifier is the most security-critical code you own, and it's pure (inputs in, boolean out), so pin it with a fixed **test vector**: a known secret, id, timestamp, and body with a pre-computed expected signature, hardcoded once and never regenerated by your own code. A fixed vector catches the case a same-code "compute it twice" check can't: if your implementation has a systemic misunderstanding of the scheme (wrong join order, wrong encoding), computing it twice in your own codebase reproduces the same mistake both times and still "passes." A hardcoded expected value only passes if your output matches the scheme itself. ```ts // Fixed inputs and a hardcoded expected output — computed once, out of band, // and never regenerated by computeSignature itself. const SECRET = 'whsec_abcdef1234567890'; const ID = '1f2e3d4c-5b6a-7980-1234-567890abcdef'; const TS = '1750000000'; const BODY = '{"field":"message","event":"message.delivered"}'; const EXPECTED = 'v1,toBvqGNFsjQ5xSZO93Z3qiyVckIbArJtTaEPaUYZJGw='; it('matches the known test vector byte-for-byte', async () => { expect(await computeSignature(ID, TS, BODY, SECRET)).toBe(EXPECTED); }); ``` Apply the same idea to your **receiver's** verify function, and cover the failure modes explicitly: - Strips the `whsec_` prefix and decodes the key as **base64** (not utf-8). - A tampered body produces a **different** signature (assert rejection). - A stale timestamp (> 300 s) is rejected: replay protection. - Rotation: a signature computed with a retired secret is **rejected**. Sent signs each delivery with exactly one signature using the current secret only, so after rotating, update the verifier's stored secret immediately. See [Going to production](/build/going-to-production) for the rotation runbook. - Verification runs on the **raw body**, before JSON parsing. Because the vector is fixed, this test is also your cross-language conformance check: hard-code the same `SECRET`/`ID`/`TS`/`BODY` in your Python, Go, or Java verifier tests and they must all produce `v1,`. See [Signature verification](/build/signature-verification). ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/build/webhook-receiver.txt TITLE: Building the Webhook Receiver ================================================================================ URL: https://docs.sent.dm/llms/build/webhook-receiver.txt Build the inbound webhook endpoint end-to-end: capture the raw body, verify the signature, acknowledge fast with a 2xx, and process events asynchronously. # Building the Webhook Receiver This is the inbound half of the integration. As a message moves through Sent (`queued → routed → sent → delivered → read`, or `failed`) and when a recipient replies, Sent `POST`s a signed event to an endpoint you registered. The receiver is a single route, `POST /webhooks/sent`, and getting it right is mostly about *order of operations*. If you're new to the event model, read [Webhooks: getting started](/start/webhooks/getting-started) and the [message lifecycle](/start/webhooks/lifecycle) first. This page assumes you know *what* the events mean and focuses on *how to receive them safely*. A webhook endpoint is a public URL that mutates your state. Treat it as a security boundary: verify every request against the raw body before you act on it. The verification itself is the next page ([Signature verification](/build/signature-verification)); this page builds the receiver *around* it. **Quick path.** Grab your framework's raw-body setup from [Why the raw body matters](#why-the-raw-body-matters), verify with the function from [Signature verification](/build/signature-verification), then follow [Acknowledge fast, process async](#acknowledge-fast-process-async): reject on a bad signature or an unparseable body, otherwise return `200` immediately and do the actual state update after. That's the whole receiver. [The pipeline](#the-pipeline) below just walks through why those five steps are in that exact order. ## The pipeline Every receiver runs the same five steps against the raw request body and the `X-Webhook-*` headers, in this exact order: *In plain English: each `{diamond}` is a yes/no check, read left to right: missing headers, a bad signature, or an unparseable body rejects early; every surviving event gets a `200`, and only a fresh one gets processed.* The ordering is deliberate: 1. **Verify**: reject with `400` if signature headers are missing, `401` if the signature doesn't match or the timestamp is stale. 2. **Parse** the JSON only after verification succeeds: reject with `400` if the body is unparseable. 3. **Dedupe** on a **per-event** key: Sent delivers *at least once*, so retries must be no-ops. Derive the key from the payload (`message_id` + `message_status`), **not** from `X-Webhook-ID` (see the warning below). A duplicate still gets a `200`; it just skips the next two steps. 4. **Acknowledge** immediately with `200 { "received": true }`. 5. **Process** the event after acknowledging: update your message store, branch on the event type. This post-ack async step is what this guide calls **the processor**. **`X-Webhook-ID` is the *endpoint's* id, not a per-event id.** It's identical on every delivery to a given endpoint (it's part of the signed content, binding the signature to that endpoint). So it's the wrong key to dedupe on. Keying off it would drop every event after the first. Build the idempotency key from the **payload** instead: `message_id` + `message_status`. Your status store is also forward-only and idempotent (see [Tracking delivery status](/build/status-tracking)), which is the real safety net; the dedupe guard just spares you redundant downstream work. ## Why the raw body matters The signature covers the **exact bytes** Sent sends: the webhook id, the timestamp, and the raw JSON body concatenated as `{id}.{timestamp}.{rawBody}`. If your framework parses the JSON and hands you a re-serialized string, key order, whitespace, and Unicode escaping can all differ from the original bytes, and the HMAC will never match. So the rule is: **capture the raw body before any JSON middleware touches it, and verify against those bytes.** Parse the JSON only *after* verification succeeds. Each framework has its own way to opt out of automatic parsing for this one route. ```ts // Mount express.raw ONLY on the receiver path, before the global json() parser. // This hands the handler a Buffer of the exact bytes Sent signed. app.use("/webhooks", express.raw({ type: "application/json" })); // In the handler: const rawBody: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? {})); ``` ```python # FastAPI: read the body as bytes BEFORE any Pydantic model binding. @router.post("/sent") async def receive(request: Request): raw_body: bytes = await request.body() id_ = request.headers.get("x-webhook-id") timestamp = request.headers.get("x-webhook-timestamp") signature = request.headers.get("x-webhook-signature") # ... verify(raw_body, ...) then json.loads(raw_body) only after ``` ```go // Echo: read the whole body into bytes; do not bind a struct first. rawBody, err := io.ReadAll(c.Request().Body) if err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"error": "cannot read body"}) } id := c.Request().Header.Get("X-Webhook-ID") timestamp := c.Request().Header.Get("X-Webhook-Timestamp") signature := c.Request().Header.Get("X-Webhook-Signature") // verify(rawBody, ...) then json.Unmarshal(rawBody, &event) after ``` ```java // Spring: bind the body as a raw String (or byte[]), NOT a parsed DTO. @PostMapping("/webhooks/sent") public ResponseEntity receive( @RequestBody String rawBody, @RequestHeader("X-Webhook-ID") String id, @RequestHeader("X-Webhook-Timestamp") String timestamp, @RequestHeader("X-Webhook-Signature") String signature) { // verify(rawBody, ...) then objectMapper.readValue(rawBody, ...) after } ``` ```csharp // ASP.NET Core: read the request body stream yourself. [HttpPost("/webhooks/sent")] public async Task Receive() { using var reader = new StreamReader(Request.Body); var rawBody = await reader.ReadToEndAsync(); var id = Request.Headers["X-Webhook-ID"].ToString(); var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString(); var signature = Request.Headers["X-Webhook-Signature"].ToString(); // Verify(rawBody, ...) then JsonSerializer.Deserialize(rawBody) after } ``` ```php // Laravel: getContent() returns the raw request body verbatim. public function receive(Request $request) { $rawBody = $request->getContent(); $id = $request->header('X-Webhook-ID'); $timestamp = $request->header('X-Webhook-Timestamp'); $signature = $request->header('X-Webhook-Signature'); // verify($rawBody, ...) then json_decode($rawBody) after } ``` ```ruby # Rails: request.raw_post is the unparsed body. Skip CSRF for this action. def receive raw_body = request.raw_post id = request.headers["X-Webhook-ID"] timestamp = request.headers["X-Webhook-Timestamp"] signature = request.headers["X-Webhook-Signature"] # verify(raw_body, ...) then JSON.parse(raw_body) after end ``` Scope the raw-body handling to the **receiver route only**. The rest of your API still wants normal JSON parsing. In Express that means mounting `express.raw` on `/webhooks` and `json()` everywhere else; in Rails it means `skip_before_action :verify_authenticity_token` on that action; in Spring it's just binding `String` instead of a DTO for that endpoint. ## Acknowledge fast, process async Sent waits for your `2xx`. If you do slow work (database writes, downstream API calls, sending email) *before* responding, you risk timing out, and Sent will retry an event you actually handled. **After 10 consecutive delivery failures, Sent auto-disables the endpoint**: not a soft warning, a hard cutoff. A slow, timing-out receiver doesn't just risk duplicate processing; ten bad responses in a row and your integration silently stops receiving webhooks at all until someone re-enables it from the Sent dashboard. So acknowledge first, then process. In a single-process app that's just responding before the `await`; in production, hand the verified event to a queue and return `200` immediately. ```ts // Verified above. Parse, dedupe, ack, THEN process. let event: WebhookEvent; try { event = WebhookEventSchema.parse(JSON.parse(rawBody.toString("utf8"))); } catch { return res.status(400).json({ error: "invalid payload" }); } // Dedupe on a per-event key (NOT X-Webhook-ID — that's the endpoint id). const { message_id, message_status } = event.payload; if (message_id && !idempotency.firstSeen(`${message_id}.${message_status}`)) { return res.status(200).json({ received: true }); // duplicate — no-op } // Acknowledge immediately so Sent doesn't retry while we work. res.status(200).json({ received: true }); void processEvent(eventType ?? event.event, event, logger); ``` ```python # Verified above. Parse, dedupe, ack; BackgroundTasks runs after the response. try: event = json.loads(raw_body) except ValueError: return JSONResponse({"error": "invalid payload"}, status_code=400) # Dedupe on a per-event key (NOT X-Webhook-ID — that's the endpoint id). payload = event.get("payload", {}) key = f"{payload.get('message_id')}.{payload.get('message_status')}" if payload.get("message_id") and not idempotency.first_seen(key): return JSONResponse({"received": True}) # duplicate — no-op background_tasks.add_task(process_event, event_type, event) return JSONResponse({"received": True}) ``` ```go // Verified above. Parse, dedupe, ack; process in a goroutine. var event WebhookEvent if err := json.Unmarshal(rawBody, &event); err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid payload"}) } // Dedupe on a per-event key (NOT X-Webhook-ID — that's the endpoint id). key := event.Payload.MessageID + "." + event.Payload.MessageStatus if event.Payload.MessageID != "" && !idempotency.FirstSeen(key) { return c.JSON(http.StatusOK, echo.Map{"received": true}) // duplicate — no-op } go processEvent(eventType, event) // process off the response path return c.JSON(http.StatusOK, echo.Map{"received": true}) ``` `idempotency` here is a placeholder for whatever's backing `firstSeen`: a plain in-memory `Set` is fine on a single instance, but the moment you run more than one, it needs to be a **shared** store: two instances can each receive the same retried delivery and both see "unseen" if they're not checking the same place. See [Idempotency across instances](/build/scaling-and-deployment#idempotency-across-instances) for the shared, atomic version (Redis `SET NX`). ## When processing fails after the ack Acknowledging first means you've told Sent the event is handled. If `processEvent` then throws (a bad payload variant, your database is down, a downstream call fails), **Sent will not retry** it for you. That failure is now entirely yours to catch: - **Don't fire-and-forget in production.** A bare `go processEvent(...)` or `void processEvent(...)` with no error handling silently drops the event on failure. At minimum, catch the error and log it with the event's `message_id` so it's findable. - **Put a real queue behind the ack**, not an in-process goroutine/task, once this matters for more than a demo: one with its own retry policy and a dead-letter queue for events that fail repeatedly. That queue can retry your *processing* even though Sent's own delivery retry is already spent. - **Alert on the DLQ as well as on errors.** A message stuck `SENT` forever because its `DELIVERED` event silently failed to process looks identical to "still in flight" unless something pages you when events land in the dead-letter queue. This is the actual failure mode that matters most for this endpoint: the inbound flow is what keeps your delivery status correct, so a swallowed processing error doesn't just lose one event. It leaves that message's status permanently stale. ## The response contract Your status code is a *signal* to Sent. Return the right one: | Situation | Status | Body | |---|---|---| | Verified & accepted | `200` | `{ "received": true }` | | Missing required signature headers | `400` | `{ "error": "missing signature headers" }` | | Signature mismatch or stale timestamp | `401` | `{ "error": "invalid signature" }` | | Unparseable JSON (*after* a valid signature) | `400` | `{ "error": "invalid payload" }` | | No signing secret known yet (none registered) | `503` | `{ "error": "no signing secret configured" }` | The `503` case is worth calling out: because the signing secret is **born when you register an endpoint** (see [Endpoint management](/build/endpoint-management)), a receiver that has never held a secret has nothing to verify against and should say so rather than silently accept or 500. Once an endpoint is registered, verification proceeds normally. **Return `2xx` even for event types you don't handle.** A non-`2xx` tells Sent the delivery failed and it will retry with backoff. If you only care about `message.delivered` and `message.failed`, still `200` the `message.queued` and `message.routed` events, just log and move on. Silence is acknowledgement. Never echo header values, the raw signature, or the signing secret in a response or log line. A rejection body should name the *reason* (`"invalid signature"`), never the offending value. ## Where the secret comes from One thing that surprises people: there is no `SENT_DM_WEBHOOK_SECRET` env var driving this in production. The signing secret is **born when a customer registers a webhook** (the create call returns it once), and the receiver verifies against that in-memory secret. That whole lifecycle is [Endpoint management](/build/endpoint-management); the verification math is [Signature verification](/build/signature-verification). ## Next steps ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/accounts/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint.txt TITLE: Get authenticated account ================================================================================ URL: https://docs.sent.dm/llms/reference/api/accounts/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint.txt # GET /v3/me Get authenticated account Returns the account associated with the provided API key. The response includes account identity, contact information, messaging channel configuration, and — depending on the account type — either a list of child profiles or the profile's own settings. **Account types:** - `organization` — Has child profiles. The `profiles` array is populated. - `user` — Standalone account with no profiles. - `profile` — Child of an organization. Includes `organization_id`, `short_name`, `status`, and `settings`. **Channels:** The `channels` object always includes `sms`, `whatsapp`, and `rcs`. Each channel has a `configured` boolean. Configured channels expose additional details such as `phone_number`. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint` **Tags:** Accounts ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Account retrieved successfully. Response shape varies by account type (organization, user, or profile). #### application/json ```typescript { success?: boolean, data?: { type?: string, id?: string, organization_id?: string, name?: string, short_name?: string, email?: string, icon?: string, description?: string, created_at?: string, channels?: unknown, status?: string, settings?: { allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string }, profiles?: Array<{ id?: string, name?: string, icon?: string, description?: string, short_name?: string, role?: string, status?: string, created_at?: string, settings?: unknown }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "type": "profile", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "organization_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "name": "Marketing", "short_name": "MKT", "email": "marketing@acme.com", "icon": "https://cdn.sent.dm/icons/marketing.png", "description": "Marketing department sender profile", "created_at": "2025-01-20T14:00:00+00:00", "channels": { "sms": { "configured": true, "phone_number": "+14155550100" }, "whatsapp": { "configured": true, "phone_number": "+14155550100", "business_name": "Acme Corporation" }, "rcs": { "configured": false, "phone_number": "+14155550100" } }, "status": "approved", "settings": { "allow_contact_sharing": true, "allow_template_sharing": true, "inherit_contacts": true, "inherit_templates": false, "inherit_tcr_brand": true, "inherit_tcr_campaign": false, "billing_model": "organization" }, "profiles": [] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5464172+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API key #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_001", "message": "Invalid or missing API key. Ensure the x-api-key header is set with a valid key.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.546419+00:00", "version": "v3" } } ``` ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving account information. Please try again later.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5464197+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/authentication.txt TITLE: Authentication ================================================================================ URL: https://docs.sent.dm/llms/reference/api/authentication.txt Sent API v3 authentication reference covering the API key request header, standard response headers, and AUTH error codes with statuses and causes. # Authentication The Sent API v3 authenticates every request with an API key passed in the `x-api-key` header. The key identifies your account; no other identifier is required. --- ## Request header | Header | Type | Required | Description | |--------|------|----------|-------------| | `x-api-key` | String (UUID) | Yes | Your secret API key. Identifies and authorizes your account on every request. | API keys are UUIDs (for example, `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). There is one key format for all environments. Sandbox behavior is controlled per request with the `"sandbox"` body field on mutation endpoints, not by a separate key type. See [Sandbox mode](/reference/api/test-mode). To create, rotate, or revoke keys, see [Creating and managing API keys](/start/guides/api-keys). ### Example request ```bash curl https://api.sent.dm/v3/me \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -H "Content-Type: application/json" ``` --- ## Response headers All v3 responses include: | Header | Description | |--------|-------------| | `X-Request-Id` | Unique request identifier for tracing and support, in the form `req_` followed by 16 hex characters. If the request supplies an `X-Request-Id` header, that value is echoed back. | | `X-Response-Time` | Server processing time in milliseconds, for example `12ms`. | | `X-API-Version` | Always `v3`. | --- ## Authentication errors Authentication failures return the standard v3 error envelope: ```json { "success": false, "status": 401, "error": { "code": "AUTH_002", "message": "Invalid or missing API key", "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_a1b2c3d4e5f60718", "timestamp": "2026-07-25T12:00:00+00:00", "version": "v3" } } ``` ### AUTH error codes | Code | Status | Message | Returned when | |------|--------|---------|---------------| | `AUTH_001` | 401 | Authentication required | The endpoint requires an authenticated account and the request has none. | | `AUTH_002` | 401 | Invalid or missing API key | The `x-api-key` header is absent, empty, or does not match an active key. | | `AUTH_003` | 401 | Missing or invalid sender identifier | Legacy v1/v2 endpoints only: the `x-sender-id` header is missing or is not a valid UUID. v3 endpoints do not return this code. | | `AUTH_004` | 403 | Access denied | The authenticated account does not have permission for the operation. See [Roles and permissions](/reference/api/roles-and-permissions). | | `AUTH_005` | 403 | Account onboarding is not complete | Defined for onboarding gating; not returned by v3 authentication. | | `AUTH_006` | 403 | KYC verification is not complete | Defined for onboarding gating; not returned by v3 authentication. | | `AUTH_007` | 403 | Channel setup is not complete | Defined for onboarding gating; not returned by v3 authentication. | Onboarding state does not block authentication. Message volume during partial onboarding is limited in the send pipeline instead: over-limit messages are accepted by the API and finalized with a `BLOCKED` status. See [Trust & Safety](/start/concepts/trust-and-safety). The complete list of API error codes is in the [Error catalog](/reference/api/error-catalog). ### Failed-attempt lockout After 10 consecutive failed authentication attempts with the same credential, the API locks that credential and returns `429` with error code `BUSINESS_002`, the message `Too many failed authentication attempts. Please try again later.`, and a `Retry-After: 60` header. The lockout duration escalates with continued failures: 1, 5, 15, 30, then 60 minutes. A successful authentication resets the counter. --- ## Rate limits Authenticated requests are rate limited per customer account. For limits, tiers, and `429` response headers, see [Rate limits](/reference/api/rate-limits). --- ## Related pages - [Creating and managing API keys](/start/guides/api-keys): create, store, verify, rotate, and revoke keys. - [API authentication](/start/concepts/api-authentication): why v3 authenticates with a single header-based key. - [Error catalog](/reference/api/error-catalog): every error code across the API. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint.txt TITLE: Create a campaign for a profile's brand ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint.txt # POST /v3/profiles/{profileId}/campaigns Create a campaign for a profile's brand Creates a new campaign scoped under the brand of the specified profile. Each campaign must include at least one use case with sample messages. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, campaign?: unknown } ``` ## Responses ### 201 Campaign created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, customerId?: string, type?: string, name?: string, description?: string, brandId?: string, status?: { }, tcrCampaignId?: string, submittedToTCR?: boolean, submittedAt?: string, cost?: number, billedDate?: string, messageFlow?: string, volume?: string, privacyPolicyLink?: string, termsAndConditionsLink?: string, optinMessage?: string, optoutMessage?: string, helpMessage?: string, optinKeywords?: string, optoutKeywords?: string, helpKeywords?: string, dcaElectionsComplete?: boolean, dcaElectionsCompletedAt?: string, tcrSyncError?: string, hasSubmissionTransaction?: boolean, useCases?: Array<{ id?: string, campaignId?: string, customerId?: string, messagingUseCaseUs?: unknown, sampleMessages?: Array, createdAt?: string, updatedAt?: string }>, createdAt?: string, updatedAt?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "type": "App", "name": "Customer Notifications", "description": "Appointment reminders and account notifications", "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": null, "tcrCampaignId": null, "submittedToTCR": false, "submittedAt": null, "cost": null, "billedDate": null, "messageFlow": "User signs up on website and opts in to receive SMS notifications", "volume": null, "privacyPolicyLink": null, "termsAndConditionsLink": null, "optinMessage": null, "optoutMessage": null, "helpMessage": null, "optinKeywords": null, "optoutKeywords": null, "helpKeywords": null, "dcaElectionsComplete": null, "dcaElectionsCompletedAt": null, "tcrSyncError": null, "hasSubmissionTransaction": false, "useCases": [ { "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "campaignId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ], "createdAt": "0001-01-01T00:00:00+00:00", "updatedAt": null } ], "createdAt": "2026-08-08T14:55:35.5390789+00:00", "updatedAt": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5391278+00:00", "version": "v3" } } ``` ### 400 Invalid request - validation errors or inherit_tcr_campaign=true #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Cannot create campaigns when inherit_tcr_campaign=true. Set inherit_tcr_campaign=false to create your own campaigns.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5391294+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile or brand not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_009", "message": "Brand not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5391299+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to create campaign. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5391303+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint.txt TITLE: Delete a campaign ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint.txt # DELETE /v3/profiles/{profileId}/campaigns/{campaignId} Delete a campaign Deletes a campaign by ID from the brand of the specified profile. The profile must belong to the authenticated organization. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route parameter | | `campaignId` | `string` | true | Campaign ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean } ``` ## Responses ### 204 Campaign deleted successfully ### 400 Invalid profile or campaign ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid profile or campaign ID format", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5411979+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile, brand, or campaign not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_010", "message": "Campaign not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.541199+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to delete campaign. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5411995+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint.txt TITLE: Get campaigns for a profile's brand ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint.txt # GET /v3/profiles/{profileId}/campaigns Get campaigns for a profile's brand Retrieves all campaigns linked to the profile's brand, including use cases and sample messages. Returns inherited campaigns if inherit_tcr_campaign=true. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Campaigns retrieved successfully #### application/json ```typescript { success?: boolean, data?: Array<{ id?: string, customerId?: string, type?: string, name?: string, description?: string, brandId?: string, status?: { }, tcrCampaignId?: string, submittedToTCR?: boolean, submittedAt?: string, cost?: number, billedDate?: string, messageFlow?: string, volume?: string, privacyPolicyLink?: string, termsAndConditionsLink?: string, optinMessage?: string, optoutMessage?: string, helpMessage?: string, optinKeywords?: string, optoutKeywords?: string, helpKeywords?: string, dcaElectionsComplete?: boolean, dcaElectionsCompletedAt?: string, tcrSyncError?: string, hasSubmissionTransaction?: boolean, useCases?: Array<{ id?: string, campaignId?: string, customerId?: string, messagingUseCaseUs?: unknown, sampleMessages?: Array, createdAt?: string, updatedAt?: string }>, createdAt?: string, updatedAt?: string }>, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": [ { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "type": "App", "name": "Customer Notifications", "description": "Appointment reminders and account notifications", "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": null, "tcrCampaignId": null, "submittedToTCR": false, "submittedAt": null, "cost": null, "billedDate": null, "messageFlow": "User signs up on website and opts in to receive SMS notifications", "volume": null, "privacyPolicyLink": null, "termsAndConditionsLink": null, "optinMessage": null, "optoutMessage": null, "helpMessage": null, "optinKeywords": null, "optoutKeywords": null, "helpKeywords": null, "dcaElectionsComplete": null, "dcaElectionsCompletedAt": null, "tcrSyncError": null, "hasSubmissionTransaction": false, "useCases": [ { "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "campaignId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ], "createdAt": "0001-01-01T00:00:00+00:00", "updatedAt": null } ], "createdAt": "2026-08-08T14:55:35.5426239+00:00", "updatedAt": null } ], "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5426248+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile or brand not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_009", "message": "Brand not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5426261+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to retrieve campaigns. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5426268+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint.txt TITLE: Update a campaign ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint.txt # PUT /v3/profiles/{profileId}/campaigns/{campaignId} Update a campaign Updates an existing campaign under the brand of the specified profile. Cannot update campaigns that have already been submitted to TCR. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | | `campaignId` | `string` | true | Campaign ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, campaign?: unknown } ``` ## Responses ### 200 Campaign updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, customerId?: string, type?: string, name?: string, description?: string, brandId?: string, status?: { }, tcrCampaignId?: string, submittedToTCR?: boolean, submittedAt?: string, cost?: number, billedDate?: string, messageFlow?: string, volume?: string, privacyPolicyLink?: string, termsAndConditionsLink?: string, optinMessage?: string, optoutMessage?: string, helpMessage?: string, optinKeywords?: string, optoutKeywords?: string, helpKeywords?: string, dcaElectionsComplete?: boolean, dcaElectionsCompletedAt?: string, tcrSyncError?: string, hasSubmissionTransaction?: boolean, useCases?: Array<{ id?: string, campaignId?: string, customerId?: string, messagingUseCaseUs?: unknown, sampleMessages?: Array, createdAt?: string, updatedAt?: string }>, createdAt?: string, updatedAt?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "type": "App", "name": "Customer Notifications Updated", "description": "Updated appointment reminders and account notifications", "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": null, "tcrCampaignId": null, "submittedToTCR": false, "submittedAt": null, "cost": null, "billedDate": null, "messageFlow": null, "volume": null, "privacyPolicyLink": null, "termsAndConditionsLink": null, "optinMessage": null, "optoutMessage": null, "helpMessage": null, "optinKeywords": null, "optoutKeywords": null, "helpKeywords": null, "dcaElectionsComplete": null, "dcaElectionsCompletedAt": null, "tcrSyncError": null, "hasSubmissionTransaction": false, "useCases": [ { "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "campaignId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ], "createdAt": "0001-01-01T00:00:00+00:00", "updatedAt": null } ], "createdAt": "2026-08-01T14:55:35.5440757+00:00", "updatedAt": "2026-08-08T14:55:35.5440765+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5440859+00:00", "version": "v3" } } ``` ### 400 Invalid request - validation errors or inherit_tcr_campaign=true #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "These campaigns are read-only. Set inherit_tcr_campaign to false to manage your own campaigns.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5440937+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile, brand, or campaign not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_010", "message": "Campaign not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5440942+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to update campaign. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5440947+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint.txt TITLE: Create a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint.txt # POST /v3/contacts Create a contact Creates a new contact by phone number and associates it with the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint` **Tags:** Contacts ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, phone_number?: string } ``` ## Responses ### 201 Contact created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms", "default_channel": "sms", "opt_out": false, "is_inherited": false, "created_at": "2026-08-08T14:55:35.5292608+00:00", "updated_at": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5292757+00:00", "version": "v3" } } ``` ### 400 Invalid request - phone number missing or invalid #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Phone number is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5292772+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 409 Contact already exists for this customer #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_007", "message": "Contact with this phone number already exists for this customer", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5292777+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5292782+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint.txt TITLE: Delete a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint.txt # DELETE /v3/contacts/{id} Delete a contact Dissociates a contact from the authenticated customer. Inherited contacts cannot be deleted. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Contact ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean } ``` ## Responses ### 204 Contact deleted successfully ### 400 Invalid contact ID or read-only contact #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid contact ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5305208+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Contact not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5305219+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5305223+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint.txt TITLE: Get contact by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint.txt # GET /v3/contacts/{id} Get contact by ID Retrieves a specific contact by their unique identifier. Returns detailed contact information including phone formats, available channels, and opt-out status. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Contact ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Contact found and returned successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "sms", "opt_out": false, "is_inherited": false, "created_at": "2026-08-08T14:55:35.5324259+00:00", "updated_at": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5324261+00:00", "version": "v3" } } ``` ### 400 Invalid request - ContactId is empty or invalid format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_003", "message": "Invalid ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5324277+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API credentials ### 403 Forbidden ### 404 Contact not found for the authenticated customer #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5324282+00:00", "version": "v3" } } ``` ### 500 Internal server error - Contact support with request ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5324286+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactMessageSummaryEndpoint.txt TITLE: Get message summary for a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactMessageSummaryEndpoint.txt # GET /v3/contacts/{contactId}/message-summary Get message summary for a contact Returns aggregate message counts, time bounds, channels used, and per-channel success/fail scores (each as a percentage 0-100 of messages on that channel) for one of your contacts. Successful terminal states: SENT/DELIVERED/READ for outbound, RECEIVED for inbound. Fail: FAILED. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsGetContactMessageSummaryEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `contactId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Summary returned successfully #### application/json ```typescript { success?: boolean, data?: { contact_id?: string, message_count?: integer, first_message_at?: string, last_message_at?: string, channels_used?: Array, channel_scores?: Array<{ channel?: string, success_score?: integer, fail_score?: integer }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "contact_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "message_count": 42, "first_message_at": "2026-07-09T14:55:35.5338549+00:00", "last_message_at": "2026-08-08T11:55:35.5338635+00:00", "channels_used": [ "sms", "whatsapp" ], "channel_scores": [ { "channel": "sms", "success_score": 91, "fail_score": 9 }, { "channel": "whatsapp", "success_score": 90, "fail_score": 10 } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.533945+00:00", "version": "v3" } } ``` ### 400 Invalid request - ContactId is empty or invalid format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ### 401 Unauthorized - Invalid or missing API credentials ### 403 Forbidden ### 404 Contact not found for the authenticated customer #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5339467+00:00", "version": "v3" } } ``` ### 500 Internal server error - Contact support with request ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint.txt TITLE: Get contacts list ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint.txt # GET /v3/contacts Get contacts list Retrieves a paginated list of contacts for the authenticated customer. Supports filtering by search term, channel, or phone number. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint` **Tags:** Contacts ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | Page number (1-indexed) | | `page_size` | `integer` | true | Number of items per page | | `search` | `string` | false | Optional search term for filtering contacts | | `channel` | `string` | false | Optional channel filter (sms, whatsapp) | | `phone` | `string` | false | Optional phone number filter (alternative to list view) | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Contacts retrieved successfully #### application/json ```typescript { success?: boolean, data?: { contacts?: Array<{ id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }>, pagination?: unknown }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "contacts": [ { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "sms", "opt_out": false, "is_inherited": false, "created_at": "2026-08-08T14:55:35.5351193+00:00", "updated_at": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 150, "total_pages": 8, "has_more": true, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.535138+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_005", "message": "Page must be greater than 0", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5351396+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Contact not found (when filtering by phone) #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found with the specified phone number", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5351401+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5351405+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint.txt TITLE: Update a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint.txt # PATCH /v3/contacts/{id} Update a contact Updates a contact's default channel and/or opt-out status. Inherited contacts cannot be updated. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Contact ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, default_channel?: string, opt_out?: boolean } ``` ## Responses ### 200 Contact updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "whatsapp", "opt_out": false, "is_inherited": false, "created_at": "2026-07-09T14:55:35.536472+00:00", "updated_at": "2026-08-08T14:55:35.5364728+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5364732+00:00", "version": "v3" } } ``` ### 400 Invalid request - read-only contact or invalid channel #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "This contact is read-only and cannot be updated.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5364751+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Contact not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.536476+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5364764+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/conversations/SentDmServicesEndpointsCustomerAPIv3ConversationsGetConversationByIdEndpoint.txt TITLE: List messages for a conversation ================================================================================ URL: https://docs.sent.dm/llms/reference/api/conversations/SentDmServicesEndpointsCustomerAPIv3ConversationsGetConversationByIdEndpoint.txt # GET /v3/conversations/{id} List messages for a conversation Retrieves a paginated list of the messages in a single conversation (scoped to the authenticated customer), ordered by created date (most recent first). **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ConversationsGetConversationByIdEndpoint` **Tags:** Conversations ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Conversation id from the route. | ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | - | | `page_size` | `integer` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Messages retrieved successfully #### application/json ```typescript { success?: boolean, data?: { messages?: Array<{ id?: string, customer_id?: string, contact_id?: string, phone?: string, phone_international?: string, region_code?: string, template_id?: string, template_name?: string, template_category?: string, channel?: string, message_body?: { header?: string, content?: string, footer?: string, buttons?: Array<{ type?: string, text?: string, value?: string, postbackData?: string }> }, status?: string, direction?: string, created_at?: string, price?: number, active_contact_price?: number, events?: Array<{ status: string, timestamp: string, description?: string }> }>, pagination?: unknown }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "messages": [ { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "customer_id": "41681f0a-15b4-45ab-afca-d9af35b4d3a1", "contact_id": "14b278db-6db8-448f-a5db-8d6ce46a3451", "phone": "+1234567890", "phone_international": "+1 234-567-890", "region_code": "US", "template_id": null, "template_name": null, "template_category": null, "channel": "sms", "message_body": null, "status": "DELIVERED", "direction": "OUTBOUND", "created_at": "2026-08-08T14:50:35.5268615+00:00", "price": 0.0075, "active_contact_price": 0, "events": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5268956+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_005", "message": "Page must be greater than 0", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5269233+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5269237+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/conversations/SentDmServicesEndpointsCustomerAPIv3ConversationsGetConversationsEndpoint.txt TITLE: List conversation messages ================================================================================ URL: https://docs.sent.dm/llms/reference/api/conversations/SentDmServicesEndpointsCustomerAPIv3ConversationsGetConversationsEndpoint.txt # GET /v3/conversations List conversation messages Retrieves a paginated list of the authenticated customer's messages across all conversations, ordered by created date (most recent first). **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ConversationsGetConversationsEndpoint` **Tags:** Conversations ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | - | | `page_size` | `integer` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Messages retrieved successfully #### application/json ```typescript { success?: boolean, data?: { messages?: Array<{ id?: string, customer_id?: string, contact_id?: string, phone?: string, phone_international?: string, region_code?: string, template_id?: string, template_name?: string, template_category?: string, channel?: string, message_body?: { header?: string, content?: string, footer?: string, buttons?: Array<{ type?: string, text?: string, value?: string, postbackData?: string }> }, status?: string, direction?: string, created_at?: string, price?: number, active_contact_price?: number, events?: Array<{ status: string, timestamp: string, description?: string }> }>, pagination?: unknown }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "messages": [ { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "customer_id": "41681f0a-15b4-45ab-afca-d9af35b4d3a1", "contact_id": "14b278db-6db8-448f-a5db-8d6ce46a3451", "phone": "+1234567890", "phone_international": "+1 234-567-890", "region_code": "US", "template_id": null, "template_name": null, "template_category": null, "channel": "sms", "message_body": null, "status": "DELIVERED", "direction": "OUTBOUND", "created_at": "2026-08-08T14:50:35.5279497+00:00", "price": 0.0075, "active_contact_price": 0, "events": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5279516+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_005", "message": "Page must be greater than 0", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5279531+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5279663+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/data-models.txt TITLE: Data Models ================================================================================ URL: https://docs.sent.dm/llms/reference/api/data-models.txt Reference for the Sent API v3 response envelope, ApiError and ApiMeta objects, and pagination metadata, plus links to per-resource request and response schemas. # Data Models Every Sent API v3 response uses the same JSON envelope. This page documents the shared structures: the response envelope, its error and metadata objects, and the pagination metadata returned by list endpoints. Request and response schemas for individual resources are generated from the OpenAPI specification and rendered on each endpoint page. See [Resource schemas](#resource-schemas). **Naming Convention:** The API v3 uses `snake_case` for all JSON property names (for example, `phone_number`, `created_at`). --- ## Response Envelope All v3 endpoints return the standard `ApiResponse` envelope: | Field | Type | Description | |-------|------|-------------| | `success` | boolean | `true` when the request succeeded, `false` when it failed | | `data` | object | The response data. Omitted from error responses | | `error` | [ApiError](#apierror) | Error details. Omitted from successful responses | | `meta` | [ApiMeta](#apimeta) | Metadata about the request and response | ### Example Success Response ```json { "success": true, "data": { "id": "0b9df168-9917-4b1e-bc5d-9cee5d2ce2d2", "phone_number": "+15551234567", "created_at": "2026-01-15T10:30:00Z" }, "meta": { "request_id": "req_abc123", "timestamp": "2026-01-15T10:30:00Z", "version": "v3" } } ``` ### Example Error Response ```json { "success": false, "status": 404, "error": { "code": "RESOURCE_001", "message": "Contact not found", "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_def456", "timestamp": "2026-01-15T10:30:00Z", "version": "v3" } } ``` [Error Handling](/reference/api/errors) documents HTTP status codes and error response semantics; the [Error Catalog](/reference/api/error-catalog) enumerates every error code with causes and remediation. ### ApiError | Field | Type | Description | |-------|------|-------------| | `code` | string | Machine-readable error code (for example, `RESOURCE_001`) | | `message` | string | Human-readable error message | | `details` | object | Field-level validation errors: a map of field name to an array of error messages. Omitted when unset | | `doc_url` | string | URL to documentation about this error. Omitted when unset | ### ApiMeta | Field | Type | Description | |-------|------|-------------| | `request_id` | string | Unique identifier for this request, for tracing and support | | `timestamp` | string (date-time) | Server timestamp when the response was generated | | `version` | string | API version used for this request (`v3`) | --- ## Paginated List Responses Paginated list endpoints return the items array and a `pagination` object inside `data`. The array property is named after the resource: | Endpoint | Array property | |----------|----------------| | [`GET /v3/contacts`](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint) | `contacts` | | [`GET /v3/templates`](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint) | `templates` | | [`GET /v3/webhooks`](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint) | `webhooks` | | [`GET /v3/webhooks/{id}/events`](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint) | `events` | | `GET /v3/conversations` | `messages` | | `GET /v3/conversations/{id}` | `messages` | Paginated endpoints accept the `page` query parameter (1-indexed, default `1`) and the `page_size` query parameter (default `20`, range 1–100). ### Example Paginated Response ```json { "success": true, "data": { "contacts": [ { "id": "0b9df168-9917-4b1e-bc5d-9cee5d2ce2d2", "phone_number": "+15551234567" } ], "pagination": { "page": 1, "page_size": 20, "total_count": 42, "total_pages": 3, "has_more": true, "cursors": null } }, "meta": { "request_id": "req_abc123", "timestamp": "2026-01-15T10:30:00Z", "version": "v3" } } ``` ### PaginationMeta | Field | Type | Description | |-------|------|-------------| | `page` | integer | Current page number (1-indexed) | | `page_size` | integer | Number of items per page | | `total_count` | integer | Total number of items across all pages | | `total_pages` | integer | Total number of pages | | `has_more` | boolean | Whether there are more pages after this one | | `cursors` | [PaginationCursors](#paginationcursors) \| null | Optional cursor pagination pointers. `null` when the response is paginated by page number only | ### PaginationCursors | Field | Type | Description | |-------|------|-------------| | `after` | string \| null | Cursor to fetch the next page | | `before` | string \| null | Cursor to fetch the previous page | --- ## Resource Schemas Request and response schemas for each resource are generated from the OpenAPI specification and rendered on the endpoint pages. Each mutation endpoint page documents its own request body; the pages below document the full response model for each resource: | Resource | Response model | Documented on | |----------|----------------|---------------| | Contacts | `ContactResponse` | [Get contact by ID](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint) | | Messages | `MessageResponse` | [Get message status](/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint) | | Templates | `TemplateResponse` | [Get template by ID](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint) | | Profiles | `ProfileDetailResponse` | [Get profile by ID](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint) | | Webhooks | `WebhookV3Response` | [Get a webhook](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint) | | Brand campaigns | `BrandCampaignV3Response` | [Get campaigns for a profile's brand](/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint) | | Users | `UserResponse` | [Get user by ID](/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint) | | Account | `AccountResponse` | [Get authenticated account](/reference/api/accounts/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint) | | Number lookup | `NumberLookupResultResponse` | [Get phone number details](/reference/api/number-lookup/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint) | ### Message Status Values The generated schema types the `status` field on `MessageResponse` as a plain string, so the endpoint page does not enumerate its values. `QUEUED`, `ROUTED`, `SENT`, `DELIVERED`, `READ`, and `FAILED` cover the outbound lifecycle, and `RECEIVED` marks an inbound message from a contact. Three more statuses describe sends that Sent held back: - `SCHEDULED`: the send fell inside the recipient's quiet hours and was deferred rather than failed. Sent releases the message automatically once the window closes and it continues through the normal send path, so `SCHEDULED` is not terminal. - `FILTERED`: a policy gate suppressed the send before any provider call, either because the recipient opted out or is on your phone-channel suppression list, or because your routing rules denied it. - `BLOCKED`: an account-level precondition stopped the send before policy evaluation, for example an insufficient balance, an unmet onboarding entitlement, or a template that is not approved for sending. `FILTERED` and `BLOCKED` are policy outcomes rather than delivery failures, so both are excluded from your deliverability rate. Only `FAILED` counts against it. Sent records why a message was filtered or blocked internally, but neither `MessageResponse` nor the webhook payload carries a field for it, and no field reports when a `SCHEDULED` message will be released. Each of the preceding statuses has a matching webhook sub-type. `PROCESSED`, which a message reports for the short interval between acceptance and routing, is the one status without a sub-type, so status webhooks never carry it. See [Event Types](/start/webhooks/event-types) for the full status catalog and the payload shape of each event. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/error-catalog.txt TITLE: Error Catalog ================================================================================ URL: https://docs.sent.dm/llms/reference/api/error-catalog.txt Every error code the Sent API v3 can return, with the HTTP status it maps to, the cause behind it, and the steps to resolve it # Error Catalog This catalog is the canonical reference for Sent API v3 error codes. Every documented code appears below with its cause and step-by-step remediation; other documentation pages link to this catalog rather than restating code details. --- ## Authentication Errors ### AUTH_001: User is not authenticated **Error Message:** "User is not authenticated" **HTTP Status:** 401 Unauthorized **Cause:** The request is missing the required `x-api-key` header. **Remediation:** 1. Ensure you're including the `x-api-key` header in all API requests 2. Verify the header name is exactly `x-api-key` (case-sensitive) 3. Check that your API key is being loaded from environment variables correctly **Example Fix:** ```bash # ❌ Missing header curl -X GET https://api.sent.dm/v3/me # ✅ Correct curl -X GET https://api.sent.dm/v3/me \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` --- ### AUTH_002: Invalid or missing API key **Error Message:** "Invalid or missing API key" **HTTP Status:** 401 Unauthorized **Cause:** The provided API key is invalid, revoked, or the `x-api-key` header is present but its value is not recognized. **Remediation:** 1. Verify your API key is correct and complete 2. Check that you're using the API key for the correct environment (each environment should have its own key) 3. Log into your [Sent Dashboard](https://app.sent.dm) and verify the key is active 4. Generate a new API key if the current one was revoked --- ### AUTH_004: Insufficient permissions **Error Message:** "Insufficient permissions for this operation" **HTTP Status:** 403 Forbidden **Cause:** Your user role doesn't have permission to perform this operation. **Remediation:** 1. Check your organization role (`owner`, `admin`, `developer`, `billing`) 2. Contact your organization owner to request additional permissions 3. Some operations require the `owner` or `admin` role **See:** [Roles and Permissions](/reference/api/roles-and-permissions) for the operations each role can perform and how to look up your own role --- ### AUTH_005: Account not yet activated **Error Message:** "Your account is not yet activated. Please wait for account activation before using the API." **HTTP Status:** 403 Forbidden **Cause:** The API key is valid and channel setup is complete, but the account is still pending final activation by Sent. **Remediation:** 1. You have completed all required setup steps, so no action is needed from your side 2. Wait for the activation confirmation email from Sent 3. Contact [support@sent.dm](mailto:support@sent.dm) if activation takes longer than expected --- ### AUTH_006: KYC verification not complete **Error Message:** "Your KYC verification is not complete. Please submit your KYC documents before using the API." **HTTP Status:** 403 Forbidden **Cause:** The API key is valid but the account has not completed KYC verification. Applies to accounts in status: `SIGNED_UP`, `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, or `KYC_RESUBMISSION_REQUESTED`. **Remediation:** 1. Log into your [Sent Dashboard](https://app.sent.dm) and complete the KYC verification flow 2. If resubmission was requested, address the flagged items and resubmit your documents 3. Contact [support@sent.dm](mailto:support@sent.dm) if you need assistance with KYC --- ### AUTH_007: Channel setup not complete **Error Message:** "Your channel setup is not complete. Please configure at least one messaging channel before using the API." **HTTP Status:** 403 Forbidden **Cause:** The API key is valid and KYC is approved, but no messaging channel (SMS or WhatsApp) has been configured. Applies to accounts in status: `KYC_COMPLETED` or `MESSAGE_COMPLIANCE_COMPLETED`. **Remediation:** 1. Log into your [Sent Dashboard](https://app.sent.dm) and complete channel setup 2. Configure at least one SMS or WhatsApp sender 3. See [Channel Setup](/start/quickstart/channel-setup) for step-by-step instructions --- ## Validation Errors ### VALIDATION_001: Request validation failed **Error Message:** "Request validation failed" **HTTP Status:** 400 Bad Request **Cause:** The request body or parameters failed validation. Check the `details` field for specific field-level errors. **Remediation:** 1. Review the `error.details` object for field-specific error messages 2. Ensure all required fields are provided 3. Verify data types match the schema (for example, strings vs numbers) **Example:** ```json { "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "to": ["'to' must contain at least one recipient"], "template": ["'template' must have either 'id' (non-empty GUID) or 'name' (non-empty string)"] } } } ``` --- ### VALIDATION_002: Invalid phone number format **Error Message:** "Invalid phone number format" **HTTP Status:** 400 Bad Request **Cause:** The phone number is not in a valid format. **Remediation:** 1. Use E.164 format: `+1234567890` 2. Include the country code (for example, `+1` for US) 3. Remove any non-numeric characters except the leading `+` **Valid Examples:** - `+1234567890` (US) - `+447911123456` (UK) - `+919876543210` (India) --- ### VALIDATION_003: Invalid GUID format **Error Message:** "Invalid GUID format" **HTTP Status:** 400 Bad Request **Cause:** A UUID field contains an invalid format. **Remediation:** 1. Ensure UUIDs follow the format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` 2. Verify the UUID is complete (36 characters including hyphens) 3. Check that you're not passing an empty string or null **Valid Example:** `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` --- ### VALIDATION_004: Required field is missing **Error Message:** "Required field is missing" **HTTP Status:** 400 Bad Request **Cause:** A required field is not present in the request body. **Remediation:** 1. Check the API documentation for required fields 2. Ensure the field name is spelled correctly (snake_case) 3. Verify the field is not null or undefined --- ### VALIDATION_005: Field value out of valid range **Error Message:** "Field value out of valid range" **HTTP Status:** 400 Bad Request **Cause:** A numeric field value is outside the allowed minimum/maximum range. **Remediation:** 1. Check the API documentation for valid ranges 2. For `retry_count`: must be between 1 and 5 3. For `timeout_seconds`: must be between 5 and 120 4. Ensure integer values are not negative where prohibited **Example:** ```json { "error": { "code": "VALIDATION_005", "message": "Field value out of valid range", "details": { "retry_count": ["Value must be between 1 and 5"] } } } ``` --- ### VALIDATION_006: Invalid enum value **Error Message:** "Invalid enum value" **HTTP Status:** 400 Bad Request **Cause:** A field value is not one of the allowed enum values. **Remediation:** 1. Check the API documentation for allowed values 2. Verify the value matches exactly (case-sensitive) 3. Common enums: channel (`sent`, `sms`, `whatsapp`, `rcs`), template category (`MARKETING`, `UTILITY`, `AUTHENTICATION`) An unrecognized channel is rejected rather than ignored, and the error message lists the accepted values. `sent` is the auto-detect value — see [Channel routing](/reference/channel-routing). --- ### VALIDATION_007: Invalid Idempotency-Key format **Error Message:** "Invalid Idempotency-Key format" **HTTP Status:** 400 Bad Request **Cause:** The idempotency key doesn't meet the format requirements. **Remediation:** 1. Use only alphanumeric characters, hyphens, and underscores 2. Keep the key between 1 and 255 characters 3. Avoid special characters such as spaces, `@`, and `#` **Valid Examples:** - `req-abc-123` - `send_msg_001` - `webhook_retry_1` --- ### VALIDATION_008: Invalid template variable value **Error Message:** varies by cause (see below) **HTTP Status:** 400 Bad Request **Cause:** A supplied template variable value is rejected before the message is accepted. Three distinct causes share this code, and the message tells you which one applies: | Message | Cause | | --- | --- | | `Variable '{name}' does not match the required pattern.` | The value fails the variable's configured validation pattern. | | `Variable '{name}' is invalid: param text cannot have new-line/tab characters or more than 4 consecutive spaces.` | The value contains a newline, carriage return, or tab, or more than four consecutive spaces. | | `Template variables cannot be empty for WhatsApp: {names}` | One or more variables were supplied with a blank value on a WhatsApp-only send. | **Remediation:** 1. Read the variable name from the error message. It names every offending variable, so you can fix them in one pass rather than one send at a time. 2. For a pattern mismatch, check the variable's pattern on the template and confirm the value matches it. 3. For invalid text, strip newlines, carriage returns, and tabs from the value, and collapse any run of more than four spaces. These are rejected because WhatsApp does not accept them in a parameter. 4. For an empty value on WhatsApp, supply a non-empty value or omit the variable if the template does not require it. WhatsApp rejects a text parameter with no text, so the send can never succeed. The empty-value rule applies only when the send is pinned to WhatsApp. On an auto-detect send (`channel` omitted) the same value is accepted, because the message can still route over another channel. --- ## Resource Errors ### RESOURCE_001: Contact not found **Error Message:** "Contact not found" **HTTP Status:** 404 Not Found **Cause:** The specified contact ID doesn't exist or doesn't belong to your account. **Remediation:** 1. Verify the contact ID is correct 2. List all contacts to find the correct ID: `GET /v3/contacts` 3. Check that you're using the correct API key for the account that owns the contact **Debug Steps:** ```bash # List contacts to find valid IDs curl -X GET https://api.sent.dm/v3/contacts \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` --- ### RESOURCE_002: Template not found **Error Message:** "Template not found" **HTTP Status:** 404 Not Found **Cause:** The specified template ID doesn't exist. **Remediation:** 1. Verify the template ID is correct 2. List all templates: `GET /v3/templates` 3. Ensure the template hasn't been deleted --- ### RESOURCE_003: Message not found **Error Message:** "Message not found" **HTTP Status:** 404 Not Found **Cause:** The specified message ID doesn't exist. **Remediation:** 1. Verify the message ID is correct 2. Note that message IDs are only available after sending 3. Messages may be purged after retention period --- ### RESOURCE_004: Customer not found **Error Message:** "Customer not found" **HTTP Status:** 404 Not Found **Cause:** The specified customer ID doesn't exist or is not accessible. **Remediation:** 1. Verify the customer ID is correct 2. Check that your API key has access to this customer 3. Contact support if the customer should exist --- ### RESOURCE_005: Organization not found **Error Message:** "Organization not found" **HTTP Status:** 404 Not Found **Cause:** The specified organization ID doesn't exist or you don't have access. **Remediation:** 1. Verify the organization ID is correct 2. Ensure you're using an organization-level API key 3. Check that your account is a member of the organization --- ### RESOURCE_006: User not found **Error Message:** "User not found" **HTTP Status:** 404 Not Found **Cause:** The specified user ID doesn't exist in your organization. **Remediation:** 1. Verify the user ID is correct 2. List organization users to find valid IDs 3. The user may have been removed from the organization --- ### RESOURCE_007: Resource already exists **Error Message:** "Resource already exists" **HTTP Status:** 409 Conflict **Cause:** Attempting to create a resource that already exists (for example, a duplicate contact). **Remediation:** 1. Check if the resource already exists using a list or get endpoint 2. Update the existing resource instead of creating 3. Use a unique identifier for your idempotency key --- ### RESOURCE_008: Webhook not found **Error Message:** "Webhook not found" **HTTP Status:** 404 Not Found **Cause:** The specified webhook ID doesn't exist. **Remediation:** 1. Verify the webhook ID is correct 2. List all webhooks: `GET /v3/webhooks` 3. Check if the webhook was deleted --- ### RESOURCE_009: Brand not found **Error Message:** "Brand not found for this profile" **HTTP Status:** 404 Not Found **Cause:** No 10DLC brand is registered for the profile, and none is inherited from its organization. **Remediation:** 1. Register the brand first; see the [10DLC Registration Guide](/start/advanced/10dlc-registration) 2. Confirm you are targeting the right profile with `GET /v3/profiles` --- ### RESOURCE_010: Campaign not found **Error Message:** "Campaign not found for this profile" **HTTP Status:** 404 Not Found **Cause:** The specified campaign ID doesn't belong to this profile's brand. **Remediation:** 1. List the profile's campaigns and reuse the returned `id` 2. Verify the campaign belongs to the brand registered for this profile --- ### RESOURCE_011: Batch not found **Error Message:** "Batch not found" **HTTP Status:** 404 Not Found **Cause:** The specified batch SMS operation doesn't exist. **Remediation:** 1. Verify the batch ID is correct 2. Check whether the batch has expired or was already processed --- ### RESOURCE_012: Phone number not found **Error Message:** "Phone number not found" **HTTP Status:** 404 Not Found **Cause:** The specified phone number isn't provisioned on your account. **Remediation:** 1. List your numbers to confirm the exact value 2. Use E.164 format (for example, `+14155550123`) --- ### RESOURCE_013: Resource not found **Error Message:** "Resource not found" **HTTP Status:** 404 Not Found **Cause:** Generic not-found fallback, returned when no more specific code applies. **Remediation:** 1. Verify the resource ID in the request path 2. Check the endpoint path is correct for the resource type --- ### RESOURCE_014: Profile not found **Error Message:** "Profile not found" **HTTP Status:** 404 Not Found **Cause:** The specified `profileId` isn't a child profile of your organization. Passing your organization's own ID also fails. **Remediation:** 1. Use a profile ID returned by `GET /v3/profiles` 2. Confirm the profile belongs to your organization --- ## Business Logic Errors ### BUSINESS_001: Cannot modify inherited contact **Error Message:** "Cannot modify inherited contact" **HTTP Status:** 400 Bad Request **Cause:** You're attempting to modify a contact that was inherited from a parent organization or shared profile. **Remediation:** 1. Create a new contact with the desired phone number 2. Contacts inherited from parent organizations are read-only 3. Use your own profile-scoped API key for contact modifications --- ### BUSINESS_002: Rate limit exceeded **Error Message:** "Rate limit exceeded" **HTTP Status:** 429 Too Many Requests **Cause:** You've exceeded the allowed number of requests per minute. **Remediation:** 1. Check the `Retry-After` header for wait time 2. Implement exponential backoff in your code 3. Consider using webhooks instead of polling 4. Contact support if you need higher limits **See:** [Rate Limits Documentation](/reference/api/rate-limits) --- ### BUSINESS_003: Insufficient account balance **Error Message:** "Insufficient balance" **HTTP Status:** 402 Payment Required **Cause:** Your account doesn't have enough credit to complete the operation. **Note:** `POST /v3/messages` does not return this error. Sends are accepted with `202`; when the balance is insufficient, each message is finalized asynchronously as `BLOCKED` and clears after a top-up. Legacy v2 send endpoints reject an out-of-balance request synchronously with this error. **Remediation:** 1. Check your current balance and add funds in [Billing → Overview](https://app.sent.dm/dashboard/billing) (the balance is not exposed through the API) 2. Review pricing for the operation you're attempting --- ### BUSINESS_004: Contact has opted out **Error Message:** "Contact has opted out of messaging" **HTTP Status:** 400 Bad Request **Cause:** An operation targeted a contact that has opted out of messaging. **Note:** `POST /v3/messages` does not return this error. Sends are accepted with `202` regardless of recipient opt-out state, and each consent-blocked message is finalized asynchronously as `FILTERED` (see `ERR_CONSENT_BLOCKED` below). **Remediation:** 1. Inspect the affected contacts via `GET /v3/contacts/{id}` and confirm their `opt_out` status. 2. Remove opted-out contacts from your messaging lists. 3. Re-engagement requires the contact to opt back in through a STOP/START style flow or via an `opt_out: false` update to the contact (where you have a verifiable record of new consent). --- ### ERR_CONSENT_BLOCKED: Per-message consent block **Where it surfaces:** - `GET /v3/messages/{id}`: `status = FILTERED` - `GET /v3/messages/{id}/activities`: a `FILTERED` activity; its `description` is the generic `Message updated to FILTERED` - The `message.filtered` webhook event The `ERR_CONSENT_BLOCKED` code and the consent-block reason are recorded internally and are not included in API responses or webhook payloads. A `FILTERED` terminal status indicates a policy block (consent or routing); check the contact's `opt_out` field to confirm a consent block. **Cause:** The send-time consent policy refused this individual message because the recipient's contact has `opt_out = true` or their phone is on the customer's phone-channel suppression list. The check runs pre-routing, so no provider call is made and the customer is not charged. **Remediation:** 1. Stop targeting this contact until they re-confirm consent. 2. If the opt-out is incorrect (for example, test data), update the contact's `opt_out` field through `PATCH /v3/contacts/{id}`. Only do this when you have a verifiable record of renewed consent. 3. For phone-level suppression entries that should be removed, contact support. --- ### BUSINESS_005: Template not approved **Error Message:** "Template not approved for sending" **HTTP Status:** 400 Bad Request **Cause:** The WhatsApp template hasn't been approved yet. **Remediation:** 1. Check template status: `GET /v3/templates/{id}` 2. Wait for WhatsApp/Meta approval (typically 24-48 hours) 3. For urgent needs, use SMS channel instead 4. Review template guidelines to ensure approval --- ### BUSINESS_006: Message cannot be modified in current state **Error Message:** "Message cannot be modified in current state" **HTTP Status:** 400 Bad Request **Cause:** The message has already been sent or is in a final state that prevents modification. **Remediation:** 1. Messages can only be modified while in `QUEUED` or `ACCEPTED` status 2. Once a message is `SENT`, `DELIVERED`, `READ`, or `FAILED`, it cannot be modified 3. Send a new message if you need to make changes --- ### BUSINESS_007: Channel not available **Error Message:** "Channel not available for this contact" **HTTP Status:** 400 Bad Request **Cause:** The requested messaging channel (SMS/WhatsApp) isn't available for this phone number. **Remediation:** 1. Check available channels for the contact: ```bash curl -X GET https://api.sent.dm/v3/contacts/{id} \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` 2. Use an available channel from `available_channels` 3. Don't specify a channel to let the API choose automatically --- ### BUSINESS_008: Operation would exceed quota **Error Message:** "Operation would exceed quota" **HTTP Status:** 400 Bad Request **Cause:** The operation would exceed your account's quota limits, such as those for messages, contacts, or templates. **Remediation:** 1. Check your current usage in the [Dashboard](https://app.sent.dm) 2. Upgrade your plan to increase quotas 3. Delete unused resources to free up quota 4. Contact support for temporary quota increases --- ### BUSINESS_010: Webhook is inactive **Error Message:** "Webhook is inactive" **HTTP Status:** 400 Bad Request **Cause:** The operation targets a webhook that is not active. Sending a test event to an inactive webhook returns this code. A webhook can be inactive because you turned it off, or because Sent turned it off automatically after repeated delivery failures. **Remediation:** 1. Check the webhook's status on the **Webhooks** page in the [Dashboard](https://app.sent.dm). 2. If Sent turned it off automatically, fix the endpoint first: confirm it is reachable and returns a 2xx within the timeout. Then turn the webhook back on. Turning it back on without fixing the endpoint will lead to Sent turning it off again. 3. Re-send the test event once the webhook is active. --- ### BUSINESS_012: Template is not active on the requested channel **Error Message:** "This template is not active on the requested channel" **HTTP Status:** 400 Bad Request **Cause:** The template exists and is approved, but not on the channel this send is pinned to. Template approval is per channel, so a template approved for SMS is not automatically usable on WhatsApp. **Remediation:** 1. Check the template's per-channel status with `GET /v3/templates/{id}`. 2. Send on a channel where the template is active, or omit `channel` to let Sent choose one that is. 3. If you need the template on that channel, submit it for approval there and wait for it to be approved. This is distinct from [BUSINESS_005](#business_005-template-not-approved), which means the template is not approved anywhere. BUSINESS_012 means it is approved, but not on the channel you asked for. --- ### BUSINESS_014: Account is suspended **Error Message:** "This account is suspended" **HTTP Status:** 403 Forbidden **Cause:** The account is suspended and cannot send messages or create templates. **Remediation:** 1. Contact support. A suspension is not something you can clear through the API. 2. Do not retry the request. Every send will return this code until the suspension is lifted, so a retry loop will not recover. --- ## Conflict Errors ### CONFLICT_001: Concurrent idempotent request **Error Message:** "Concurrent idempotent request in progress" **HTTP Status:** 409 Conflict **Cause:** Another request with the same idempotency key is currently being processed. **Remediation:** 1. Wait for the original request to complete 2. Use a unique idempotency key for each distinct operation 3. Don't reuse idempotency keys across different operations --- ## Service Errors ### SERVICE_001: Cache service temporarily unavailable **Error Message:** "Cache service temporarily unavailable. Please retry your request." **HTTP Status:** 503 Service Unavailable **Cause:** The cache backing idempotency processing is unavailable, so the API cannot guarantee your request is not a duplicate. Returned on requests that carry an `Idempotency-Key` header while the cache is down. **Remediation:** 1. Retry the request after a short delay, reusing the same `Idempotency-Key` 2. Check [API Status](https://status.sent.dm) for known issues --- ## Internal Errors ### INTERNAL_001: Unexpected internal server error **Error Message:** "Unexpected internal server error" **HTTP Status:** 500 Internal Server Error **Cause:** An unexpected error occurred on the server. **Remediation:** 1. Retry the request after a short delay 2. If the error persists, contact support with: - The `request_id` from the response - Timestamp of the error - The operation you were attempting --- ### INTERNAL_002: Database operation failed **Error Message:** "Database operation failed" **HTTP Status:** 500 Internal Server Error **Cause:** An unexpected database error occurred while processing your request. **Remediation:** 1. Retry the request after a short delay 2. If the error persists, contact support with the request ID 3. This is typically a transient issue --- ### INTERNAL_003: External service error **Error Message:** "External service error (SMS/WhatsApp provider)" **HTTP Status:** 500 Internal Server Error **Cause:** The upstream messaging provider is experiencing issues. **Remediation:** 1. Wait a few minutes and retry 2. Check [API Status](https://status.sent.dm) for known issues 3. The message is queued and retried automatically --- ### INTERNAL_004: Timeout waiting for operation **Error Message:** "Timeout waiting for operation" **HTTP Status:** 504 Gateway Timeout **Cause:** The operation timed out while waiting for an external service or internal processing. **Remediation:** 1. The operation may still be in progress - check the resource status 2. Retry the request with the same idempotency key 3. Contact support if timeouts persist --- ### INTERNAL_005: Service temporarily unavailable **Error Message:** "Service temporarily unavailable" **HTTP Status:** 503 Service Unavailable **Cause:** The API is temporarily unavailable due to maintenance or high load. **Remediation:** 1. Retry with exponential backoff 2. Check [API Status](https://status.sent.dm) 3. Wait for service restoration --- ## Troubleshooting Guide ### General Troubleshooting Steps 1. **Check the Error Code**: Use the error code to find its entry in this catalog 2. **Review Request ID**: Include `meta.request_id` when contacting support 3. **Verify API Version**: Ensure you're using v3 endpoints (`/v3/`) 4. **Test in Sandbox Mode**: Use `sandbox: true` to validate without side effects ### Getting Help If you can't resolve an error: 1. **Documentation**: Check this catalog and the [Error Handling](/reference/api/errors) guide 2. **Support email**: [support@sent.dm](mailto:support@sent.dm) 3. **Include in Support Request:** - Request ID (`meta.request_id`) - Error code and message - Timestamp of occurrence - Endpoint and method - Request payload (sanitized) --- ## Error Code Quick Reference Every code documented on this page, in page order: | Code | Category | HTTP Status | Quick Fix | |------|----------|-------------|-----------| | AUTH_001 | Authentication | 401 | Add `x-api-key` header | | AUTH_002 | Authentication | 401 | Verify/regenerate API key | | AUTH_004 | Authentication | 403 | [Check user permissions](/reference/api/roles-and-permissions) | | AUTH_005 | Authentication | 403 | Wait for account activation | | AUTH_006 | Authentication | 403 | Complete KYC verification | | AUTH_007 | Authentication | 403 | Configure a messaging channel | | VALIDATION_001 | Validation | 400 | Check `error.details` | | VALIDATION_002 | Validation | 400 | Use E.164 phone format | | VALIDATION_003 | Validation | 400 | Check UUID format | | VALIDATION_004 | Validation | 400 | Provide all required fields | | VALIDATION_005 | Validation | 400 | Check value is within range | | VALIDATION_006 | Validation | 400 | Check enum values | | VALIDATION_007 | Validation | 400 | Fix `Idempotency-Key` format | | VALIDATION_008 | Validation | 400 | Fix the template variable value named in the message | | RESOURCE_001 | Resource | 404 | Verify contact ID exists | | RESOURCE_002 | Resource | 404 | Verify template ID exists | | RESOURCE_003 | Resource | 404 | Verify message ID exists | | RESOURCE_004 | Resource | 404 | Verify customer ID exists | | RESOURCE_005 | Resource | 404 | Verify organization ID | | RESOURCE_006 | Resource | 404 | Verify user ID exists | | RESOURCE_007 | Resource | 409 | Update the existing resource | | RESOURCE_008 | Resource | 404 | Verify webhook ID exists | | RESOURCE_009 | Resource | 404 | Register a brand for the profile | | RESOURCE_010 | Resource | 404 | Verify campaign belongs to the profile's brand | | RESOURCE_011 | Resource | 404 | Verify batch ID exists | | RESOURCE_012 | Resource | 404 | Verify the number is provisioned (E.164) | | RESOURCE_013 | Resource | 404 | Verify the resource ID and endpoint path | | RESOURCE_014 | Resource | 404 | Use a profile ID from `GET /v3/profiles` | | BUSINESS_001 | Business Logic | 400 | Create new contact instead | | BUSINESS_002 | Business Logic | 429 | Implement backoff | | BUSINESS_003 | Business Logic | 402 | Add account funds | | BUSINESS_004 | Business Logic | 400 | Remove opted-out contacts | | ERR_CONSENT_BLOCKED | Business Logic | n/a (message `FILTERED`) | Stop targeting the contact | | BUSINESS_005 | Business Logic | 400 | Wait for template approval | | BUSINESS_006 | Business Logic | 400 | Send a new message instead | | BUSINESS_007 | Business Logic | 400 | Use an available channel | | BUSINESS_008 | Business Logic | 400 | Check quota limits | | BUSINESS_010 | Business Logic | 400 | Re-enable the webhook after fixing the endpoint | | BUSINESS_012 | Business Logic | 400 | Use a channel the template is active on | | BUSINESS_014 | Business Logic | 403 | Contact support; do not retry | | CONFLICT_001 | Conflict | 409 | Wait for the original request | | SERVICE_001 | Service | 503 | Retry after a short delay | | INTERNAL_001 | Internal | 500 | Retry or contact support | | INTERNAL_002 | Internal | 500 | Retry or contact support | | INTERNAL_003 | Internal | 500 | Retry after a delay | | INTERNAL_004 | Internal | 504 | Retry or contact support | | INTERNAL_005 | Internal | 503 | Retry with backoff | **Need More Help?** Contact [support@sent.dm](mailto:support@sent.dm) with your request ID for personalized assistance. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/errors.txt TITLE: Error Handling ================================================================================ URL: https://docs.sent.dm/llms/reference/api/errors.txt Understanding error responses, HTTP status codes, and troubleshooting common issues in the Sent API v3 # Error Handling All errors in the Sent API v3 follow a consistent JSON envelope format with structured error codes, making it easy to programmatically handle errors and troubleshoot issues. --- ## Error Response Format All errors follow a consistent JSON envelope: ```json { "success": false, "status": 404, "error": { "code": "RESOURCE_001", "message": "Contact not found", "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_a1b2c3d4e5f60718", "timestamp": "2026-01-15T10:30:00Z", "version": "v3" } } ``` ### Top-level Fields | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Always `false` on an error response | | `status` | integer | HTTP status code, repeated in the body | | `error` | object | The error itself, described below | | `meta` | object | Request metadata, described below | An error response carries no `data` field. ### Error Object Fields | Field | Type | Description | |-------|------|-------------| | `code` | string | Machine-readable error code (for example, `RESOURCE_001`) | | `message` | string | Human-readable error message | | `details` | object | Field-level validation errors, as a map of field name to an array of messages. Omitted when there are none | | `doc_url` | string | Link to the documentation page for this class of error. Omitted when unset | `details` and `doc_url` are omitted rather than sent as `null`, so read them defensively. ### Meta Object Fields | Field | Type | Description | |-------|------|-------------| | `request_id` | string | Unique request identifier for support and debugging | | `timestamp` | string | ISO 8601 timestamp of the error | | `version` | string | API version (always `v3`) | --- ## HTTP Status Codes | Status | Description | Common Causes | |--------|-------------|---------------| | `200 OK` | Request successful | - | | `201 Created` | Resource created successfully | - | | `204 No Content` | Request successful, no response body | DELETE operations | | `400 Bad Request` | Invalid request format or parameters | Missing required fields, invalid JSON | | `401 Unauthorized` | Authentication failed | Missing or invalid API key | | `403 Forbidden` | Permission denied | Insufficient role permissions | | `404 Not Found` | Resource not found | Invalid resource ID | | `409 Conflict` | Resource conflict | Duplicate entry, concurrent modification | | `422 Unprocessable Entity` | Validation failed | Invalid field values, business rule violations | | `429 Too Many Requests` | Rate limit exceeded | Too many requests in time window | | `500 Internal Server Error` | Unexpected server error | Server-side issue | | `502 Bad Gateway` | Upstream service error | Provider service unavailable | | `503 Service Unavailable` | Service temporarily unavailable | Maintenance or overload | --- ## Error Code Reference The [Error Catalog](/reference/api/error-catalog) is the canonical enumeration of every error code, with causes and step-by-step remediation. The codes below are the ones client code most commonly branches on: | Code | HTTP Status | Meaning | Handling | |------|-------------|---------|------------| | `AUTH_001` | 401 | User is not authenticated | Include the `x-api-key` header with a valid API key | | `AUTH_002` | 401 | Invalid or missing API key | Verify the key; regenerate it if revoked | | `VALIDATION_001` | 400 | Request validation failed | Check the `details` field for field-level errors | | `RESOURCE_001` | 404 | Contact not found | Verify the contact ID exists and belongs to your account | | `BUSINESS_002` | 429 | Rate limit exceeded | Back off and respect the `Retry-After` header | | `CONFLICT_001` | 409 | Concurrent idempotent request in progress | Wait for the original request to complete before retrying | | `SERVICE_001` | 503 | Cache service temporarily unavailable | Retry the request after a short delay | | `INTERNAL_001` | 500 | Unexpected internal server error | Retry, then contact support with the request ID | For the full `AUTH_*`, `VALIDATION_*`, `RESOURCE_*`, `BUSINESS_*`, `CONFLICT_*`, `SERVICE_*`, and `INTERNAL_*` listings, refer to the [Error Code Quick Reference](/reference/api/error-catalog#error-code-quick-reference) in the Error Catalog. **Example Validation Error with Details** (`POST /v3/messages` with an empty `to` array and a `template` missing both `id` and `name`): ```json { "success": false, "status": 400, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "to": ["'to' must contain at least one recipient"], "template": ["'template' must have either 'id' (non-empty GUID) or 'name' (non-empty string)"] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { ... } } ``` ### Send-time Error Codes (ERR_*) These codes are produced by the per-message send pipeline. They are not returned in the HTTP response. `POST /v3/messages` responds `202` for the batch, and each message is finalized asynchronously with a terminal status: | Code | Terminal status | Trigger | What it means | |------|-----------------|---------|---------------| | `ERR_CONSENT_BLOCKED` | `FILTERED` | Per-message consent gate | The recipient's contact has `opt_out = true`, or their phone is on your phone-channel suppression list. The send is suppressed pre-routing: no provider call is made and no charge applies. | | `ERR_ROUTE_DENIED` | `FILTERED` | Per-message routing gate | Routing rules denied every candidate route for this send, and no fallback was allowed. | | `ERR_TEMPLATE_PARAMS_INVALID` | `FAILED` | Per-message template validation | Required template variables were missing or failed validation. | Track each message's outcome through: - `GET /v3/messages/{id}`: the `status` field carries the terminal status (`FILTERED` or `FAILED`) - `GET /v3/messages/{id}/activities`: a `FILTERED` or `FAILED` activity; activity `description` values are generic (`Message updated to FILTERED`) and do not carry the ERR_* code or reason - The `message.filtered` and `message.failed` webhook events The ERR_* code and detailed reason are recorded internally and are not currently included in API responses or webhook payloads. Contact [support@sent.dm](mailto:support@sent.dm) with the message ID if you need the exact reason. --- ## Common Error Scenarios ### Authentication Issues **Missing API Key** ```bash curl -X GET https://api.sent.dm/v3/me # Response: 401 AUTH_001 ``` **Solution:** Include the `x-api-key` header: ```bash curl -X GET https://api.sent.dm/v3/me \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` ### Validation Issues **Invalid Phone Number** A `POST /v3/messages` request with a recipient that is not in E.164 format returns: ```json { "success": false, "status": 400, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "to": ["Each entry in 'to' must be a valid phone number in E.164 format (e.g. +14155551234)"] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" } } ``` **Solution:** Use E.164 format for every entry in `to`: ```json { "to": ["+14155551234"] } ``` ### Resource Not Found **Contact Not Found** ```json { "success": false, "status": 404, "error": { "code": "RESOURCE_001", "message": "Contact not found", "doc_url": "https://docs.sent.dm/reference/api/error-catalog" } } ``` **Solution:** Verify the contact ID exists: ```bash curl -X GET https://api.sent.dm/v3/contacts \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` ### Rate Limiting **Too Many Requests** ```json { "success": false, "status": 429, "error": { "code": "BUSINESS_002", "message": "Rate limit exceeded", "doc_url": "https://docs.sent.dm/reference/api/rate-limits" } } ``` **Response Headers:** ```http Retry-After: 60 X-RateLimit-Limit: 200 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1705312800 ``` **Solution:** Implement exponential backoff and respect the `Retry-After` header. See [Rate Limits](/reference/api/rate-limits) for per-endpoint limit values and the full set of rate limit headers. --- ## Related guides - [How to handle Sent API errors](/start/guides/handling-api-errors): success-flag checks, error-code branching, request-ID logging, client-side validation, and testing error paths with sandbox mode. - [How to retry Sent API requests safely](/start/guides/retrying-requests-safely): retry loops that use idempotency keys to prevent duplicate operations. - [How to handle Sent API rate limits](/start/guides/handling-rate-limits): backoff, monitoring, and throttling for `429` responses. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/idempotency.txt TITLE: Idempotency ================================================================================ URL: https://docs.sent.dm/llms/reference/api/idempotency.txt Idempotency-Key contract for the Sent API v3: supported methods, key format, response caching, replay headers, and concurrency behavior. # Idempotency The Sent API v3 supports idempotency for safely retrying requests without accidentally performing the same operation twice. With an idempotency key, the API guarantees **at-most-once execution** for a mutation: duplicate requests with the same key return the original response instead of executing again. For retry loops and client implementations that use this contract, see [How to retry Sent API requests safely](/start/guides/retrying-requests-safely). --- ## How It Works 1. The client generates a unique key for each distinct operation 2. The client sends it in the `Idempotency-Key` header 3. The API caches the successful response for 24 hours 4. Duplicate requests with the same key return the cached response ```http POST /v3/messages Idempotency-Key: msg_send_abc123 Content-Type: application/json { "to": ["+1234567890"], "template": { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "parameters": { "customer_name": "John" } } } ``` --- ## Supported Requests Idempotency applies to every `POST`, `PUT`, and `PATCH` request to a `/v3` endpoint that carries the `Idempotency-Key` header. | Condition | Behavior | |-----------|----------| | `POST`, `PUT`, or `PATCH` to `/v3/*` with `Idempotency-Key` | Idempotency processing applies | | `POST`, `PUT`, or `PATCH` without `Idempotency-Key` | Request executes normally; the header is optional | | `GET` or `DELETE` request | The `Idempotency-Key` header is ignored | --- ## Idempotency Key Format ### Requirements - **Length**: 1-255 characters - **Characters**: Alphanumeric, hyphens (`-`), and underscores (`_`) - **Pattern**: `^[a-zA-Z0-9_-]+$` - **Scope**: Per customer account - **Expiration**: 24 hours A request with a malformed key is rejected with `400 Bad Request` and error code `VALIDATION_007` (`Invalid Idempotency-Key format`). ### Valid Examples ``` req-abc123 send_msg_001 webhook-retry-1 invoice-payment-2024-001 create-contact-john-doe ``` ### Invalid Examples ``` req abc 123 # Contains spaces create@contact # Contains special character @ send.msg.001 # Contains periods ``` --- ## Response Caching | Rule | Behavior | |------|----------| | Cached responses | Only successful (2xx) responses are cached | | Error responses | Not cached, so a retry with the same key executes the request again | | Cache lifetime | 24 hours from the original response | | Response size limit | Responses larger than 5 MB are not cached; duplicates execute again | | Replayed content | The original status code and body, byte for byte | The request body is **not** compared on replay. A request that reuses a key within 24 hours receives the cached response even if its payload differs from the original request. After the 24-hour lifetime the key expires, and a request that reuses it executes as a new operation. Never reuse a key for a different operation. A duplicate key returns the original operation's cached response, and the second operation is silently never performed. If the cache backing idempotency is unavailable, the API rejects idempotent requests with `503 Service Unavailable` and error code `SERVICE_001` rather than risking a duplicate execution. --- ## Concurrent Requests When a duplicate request arrives while the original request with the same key is still executing: - The duplicate waits up to 5 seconds for the original to complete, then returns the cached response. - If the original has not completed within that window, the duplicate is rejected with `409 Conflict` and error code `CONFLICT_001`. Retrying with the same key after the original completes returns the cached response. ```json { "success": false, "status": 409, "error": { "code": "CONFLICT_001", "message": "A request with this Idempotency-Key is currently being processed. Please retry shortly.", "doc_url": "https://docs.sent.dm/reference/api/idempotency" }, "meta": { ... } } ``` --- ## Response Headers ### Idempotent-Replayed When a cached response is returned, the `Idempotent-Replayed: true` header is included: ```http HTTP/1.1 201 Created Idempotent-Replayed: true X-Original-Request-Id: req_original_abc123 X-Request-Id: req_replay_def456 Content-Type: application/json { "success": true, "data": { ... }, "error": null, "meta": { ... } } ``` ### Header Reference | Header | Description | |--------|-------------| | `Idempotent-Replayed` | `true` if this is a cached response | | `X-Original-Request-Id` | Request ID of the original request | | `X-Request-Id` | Request ID of the current request | --- ## Idempotency vs Sandbox Mode Both features help with safe API usage, but serve different purposes: | Feature | Purpose | Side Effects | Response | |---------|---------|--------------|----------| | **Sandbox Mode** | Validate requests | None (validation only) | Fake/sample data | | **Idempotency** | Prevent duplicates | Only on first request | Real/cached data | The two features can be combined in one request: a `sandbox: true` payload sent with an `Idempotency-Key` is validated without side effects, and its response is cached and replayed like any other. Refer to the [Sandbox Mode reference](/reference/api/test-mode) for the sandbox contract. --- ## Related guides - [How to retry Sent API requests safely](/start/guides/retrying-requests-safely): key derivation, retry loops that reuse keys, `CONFLICT_001` handling, and a reusable client implementation. - [How to handle Sent API errors](/start/guides/handling-api-errors): error envelope handling in client code. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api.txt TITLE: Sent v3 API ================================================================================ URL: https://docs.sent.dm/llms/reference/api.txt Complete v3 API documentation for Sent's intelligent multi-channel messaging platform # Sent v3 API The Sent v3 API sends SMS, WhatsApp, and RCS messages through a single REST interface. This section documents the API's authentication, rate limits, sandbox mode, data models, and error semantics. ## Quick Start ### Sent API Base URL ``` https://api.sent.dm ``` Every request requires an API key. See the [Authentication reference](/reference/api/authentication) for the header, key management, and error codes. If you are new to Sent, the [Quickstart Guide](/start/quickstart) walks through sending your first message. --- ## Authentication & Security --- ## API Reference Schemas, error handling, and advanced integration patterns. --- ## Postman Collection Import the v3 collection into Postman and start testing immediately with pre-configured requests, example bodies, and documentation links: --- ## Support & Resources - **[Quickstart Guide](/start/quickstart)** - Complete onboarding walkthrough - **[Support Center](/start/reference-guides/support)** - FAQs, troubleshooting, and community resources - **[API Status](https://status.sent.dm)** - Real-time service status and incident reports - **Support email**: [support@sent.dm](mailto:support@sent.dm) --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint.txt TITLE: Get message activities ================================================================================ URL: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint.txt # GET /v3/messages/{id}/activities Get message activities Retrieves the activity log for a specific message. Activities track the message lifecycle including acceptance, processing, sending, delivery, and any errors. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint` **Tags:** Messages ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Message ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Message activities retrieved successfully #### application/json ```typescript { success?: boolean, data?: { message_id?: string, activities?: Array<{ status?: string, description?: string, from?: string, timestamp?: string, price?: string, active_contact_price?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "activities": [ { "status": "DELIVERED", "description": "Message delivered to recipient", "from": "+15551234567", "timestamp": "2026-08-08T14:30:35.5203733+00:00", "price": "0.0450", "active_contact_price": "0.0050" }, { "status": "SENT", "description": "Message sent via SMS", "from": "+15551234567", "timestamp": "2026-08-08T14:25:35.5204271+00:00", "price": "0.0450", "active_contact_price": "0.0050" }, { "status": "PROCESSED", "description": "Message processed and queued for sending", "from": null, "timestamp": "2026-08-08T14:24:35.5204273+00:00", "price": "0.0450", "active_contact_price": "0.0050" }, { "status": "QUEUED", "description": "Message accepted and queued for processing", "from": null, "timestamp": "2026-08-08T14:23:35.5204274+00:00", "price": null, "active_contact_price": null } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5204337+00:00", "version": "v3" } } ``` ### 400 Invalid message ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Message not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_003", "message": "Message not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5204362+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving message activities.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5204367+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint.txt TITLE: Get message status ================================================================================ URL: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint.txt # GET /v3/messages/{id} Get message status Retrieves the current status and details of a message by ID. Includes delivery status, timestamps, and error information if applicable. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint` **Tags:** Messages ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Message ID | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Message retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, customer_id?: string, contact_id?: string, phone?: string, phone_international?: string, region_code?: string, template_id?: string, template_name?: string, template_category?: string, channel?: string, message_body?: { header?: string, content?: string, footer?: string, buttons?: Array<{ type?: string, text?: string, value?: string, postbackData?: string }> }, status?: string, direction?: string, created_at?: string, price?: number, active_contact_price?: number, events?: Array<{ status: string, timestamp: string, description?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "customer_id": "550e8400-e29b-41d4-a716-446655440000", "contact_id": "550e8400-e29b-41d4-a716-446655440002", "phone": "+14155551234", "phone_international": "+1 415-555-1234", "region_code": "US", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "Welcome Message", "template_category": "UTILITY", "channel": "sms", "message_body": { "header": null, "content": "Welcome to our service, John! We're excited to have you.", "footer": null, "buttons": null }, "status": "DELIVERED", "direction": "OUTBOUND", "created_at": "2026-08-08T12:55:35.5225085+00:00", "price": 0.0055, "active_contact_price": 0.015, "events": [ { "status": "QUEUED", "timestamp": "2026-08-08T12:55:35.522562+00:00", "description": "Message queued for sending" }, { "status": "SENT", "timestamp": "2026-08-08T12:55:40.5225746+00:00", "description": "Message sent via SMS" }, { "status": "DELIVERED", "timestamp": "2026-08-08T12:55:45.5225771+00:00", "description": "Message delivered to recipient" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5226073+00:00", "version": "v3" } } ``` ### 400 Invalid message ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid message ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5226103+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Message not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_003", "message": "Message not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5226124+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving the message.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.522613+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint.txt TITLE: Send a message ================================================================================ URL: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint.txt # POST /v3/messages Send a message Sends a message to one or more recipients using a template. Supports multi-channel broadcast — when multiple channels are specified (e.g. ["sms", "whatsapp"]), a separate message is created for each (recipient, channel) pair. Returns immediately with per-recipient message IDs for async tracking via webhooks or the GET /messages/{id} endpoint. Account-level preconditions such as insufficient balance do not reject the request: the send is accepted with 202 and the affected messages are reported as BLOCKED on GET /messages/{id} and the message status webhook. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint` **Tags:** Messages ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, to?: Array, channel?: Array, template?: { id?: string, name?: string, parameters?: object }, text?: string } ``` ## Responses ### 202 Message accepted for processing #### application/json ```typescript { success?: boolean, data?: { status?: string, template_id?: string, template_name?: string, recipients?: Array<{ message_id?: string, to?: string, channel?: string, body?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "sms", "body": "Hi John Doe, your order #12345 has been confirmed." }, { "message_id": "8ba7b831-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "whatsapp", "body": "Hi John Doe, your order #12345 has been confirmed." }, { "message_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "to": "+14155555678", "channel": "sms", "body": "Hi John Doe, your order #12345 has been confirmed." }, { "message_id": "9ba7b841-9dad-11d1-80b4-00c04fd430c8", "to": "+14155555678", "channel": "whatsapp", "body": "Hi John Doe, your order #12345 has been confirmed." } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.524685+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_004", "message": "Request validation failed", "details": { "to": [ "'to' must contain at least one recipient" ], "template": [ "'template' is required" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5246879+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5246885+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to queue message for processing.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.524689+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/number-lookup/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint.txt TITLE: Get phone number details ================================================================================ URL: https://docs.sent.dm/llms/reference/api/number-lookup/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint.txt # GET /v3/numbers/lookup/{phoneNumber} Get phone number details Retrieves detailed information about a phone number including carrier, line type, porting status, and VoIP detection. Uses the customer's messaging provider for rich data, with fallback to the internal index. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint` **Tags:** Numbers ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `phoneNumber` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Phone number details returned successfully #### application/json ```typescript { success?: boolean, data?: { phone_number?: string, is_valid?: boolean, carrier_name?: string, line_type?: string, country_code?: string, mobile_country_code?: string, mobile_network_code?: string, is_ported?: boolean, is_voip?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "phone_number": "+12025551234", "is_valid": true, "carrier_name": "T-Mobile", "line_type": "mobile", "country_code": "US", "mobile_country_code": "310", "mobile_network_code": "260", "is_ported": false, "is_voip": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.516256+00:00", "version": "v3" } } ``` ### 400 Invalid request - Phone number is missing or invalid #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Phone number is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5162589+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API credentials ### 403 Forbidden ### 404 Phone number not found in provider or internal index #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_013", "message": "Phone number not found or invalid", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5162594+00:00", "version": "v3" } } ``` ### 500 Internal server error - Contact support with request ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.5162599+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint.txt TITLE: Complete profile setup ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint.txt # POST /v3/profiles/{profileId}/complete Complete profile setup Final step in the profile compliance workflow. Validates all prerequisites (KYC, brand, campaigns, required documents), connects the profile to the SMS and WhatsApp channels, and sets its status based on configuration. Prerequisites are always validated first: if any fail the call returns 400. If they pass and the profile is already completed, the call returns 200 and does nothing. Otherwise it returns 202 and calls the provided webhook URL when background processing finishes. Prerequisites: - Profile must have a name, short name, and description (short name max 50 characters, description max 5000) - webHookUrl must be supplied on the request - A KYC form submission is required - A brand is required, either on the profile or inherited from the parent organization - TCR applications must have at least one campaign, own or inherited - Destination countries marked as main must have their required compliance documents uploaded Resulting status: - If either the SMS or WhatsApp channel is unconfigured, the profile is SUBMITTED - For a TCR application that inherits both its brand and its campaigns, the profile is COMPLETED - For a TCR application that owns either its brand or its campaigns, the profile is COMPLETED once both have been submitted to TCR, and SUBMITTED until then - For a non-TCR application, the profile is SUBMITTED when a main destination country is set, and COMPLETED otherwise **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, webHookUrl?: string } ``` ## Responses ### 200 Profile is already completed - returns current status #### application/json ```typescript { success?: boolean, data?: { status?: string, message?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "status": "completed", "message": "Profile is already completed" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4551124+00:00", "version": "v3" } } ``` ### 202 Profile completion started successfully - webhook will be called when finished #### application/json ```typescript { success?: boolean, data?: { message?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "message": "Profile completion in progress" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4551243+00:00", "version": "v3" } } ``` ### 400 Invalid request - validation errors or prerequisites not met #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ### 401 Unauthorized - Valid profile-scoped API key required ### 403 Forbidden - API key does not have access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ### 500 Internal server error occurred during validation #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint.txt TITLE: Create a new profile ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint.txt # POST /v3/profiles Create a new profile Creates a new sender profile within an organization. Profiles represent different brands, departments, or use cases, each with their own messaging configuration and settings. Requires admin role in the organization. ## WhatsApp Business Account Every profile must be linked to a WhatsApp Business Account. There are two ways to do this: **1. Inherit from organization (default)** — Omit the `whatsapp_business_account` field. The profile will share the organization's WhatsApp Business Account, which must have been set up via WhatsApp Embedded Signup. This is the recommended path for most use cases. **2. Direct credentials** — Provide a `whatsapp_business_account` object with `waba_id`, `phone_number_id`, and `access_token`. Use this when the profile needs its own independent WhatsApp Business Account. Obtain these from Meta Business Manager by creating a System User with `whatsapp_business_messaging` and `whatsapp_business_management` permissions. If the `whatsapp_business_account` field is omitted and the organization has no WhatsApp Business Account configured, the request will be rejected with HTTP 422. ## Brand Include the optional `brand` field to create the brand for this profile at the same time. Cannot be used when `inherit_tcr_brand` is `true`. ## Payment Details When `billing_model` is `"profile"` or `"profile_and_organization"` you may include a `payment_details` object containing the card number, expiry (MM/YY), CVC, and billing ZIP code. Payment details are **never stored** on our servers and are forwarded directly to the payment processor. Providing `payment_details` when `billing_model` is `"organization"` is not allowed. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint` **Tags:** Profiles ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, name?: string, icon?: string, description?: string, short_name?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, whatsapp_business_account?: { waba_id?: string, phone_number_id?: string, access_token?: string }, brand?: { contact?: unknown, business?: { legalName?: string, taxId?: string, taxIdType?: string, entityType?: { }, street?: string, city?: string, state?: string, postalCode?: string, country?: string, url?: string, countryOfRegistration?: string }, compliance?: unknown }, payment_details?: { card_number?: string, expiry?: string, cvc?: string, zip_code?: string } } ``` ## Responses ### 201 Profile created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Sales Team", "email": "team@acme.com", "icon": "https://example.com/sales-icon.png", "description": "Sales department sender profile", "short_name": "SALES", "status": "incomplete", "created_at": "2026-08-08T14:55:35.4579223+00:00", "updated_at": "2026-08-08T14:55:35.4579291+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": true, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "profile", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": "123456789012345", "billing_contact": { "name": "Acme Corp", "email": "billing@acmecorp.com", "phone": "+12025551234", "address": "123 Main Street, New York, NY 10001, US" }, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-08-08T14:55:35.458036+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [ { "id": "US", "isMain": false } ], "notes": null } } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4581815+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "name": [ "Profile name is required" ], "short_name": [ "short_name must be 3–11 characters, contain only letters, numbers, and spaces, and include at least one letter" ], "payment_details.expiry": [ "payment_details.expiry must be in MM/YY format (e.g. '09/27')" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4581856+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4581862+00:00", "version": "v3" } } ``` ### 422 Organization has no WABA configured and no direct credentials provided #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Organization does not have a WhatsApp Business Account configured. Complete WhatsApp Embedded Signup for your organization first, or provide direct credentials in the 'whatsapp_business_account' field.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4581867+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to create profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4581915+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesDeleteProfileEndpoint.txt TITLE: Delete a profile ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesDeleteProfileEndpoint.txt # DELETE /v3/profiles/{profileId} Delete a profile Soft deletes a sender profile. The profile will be marked as deleted but data is retained. Requires admin role in the organization. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesDeleteProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean } ``` ## Responses ### 204 Profile deleted successfully ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_014", "message": "Profile not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4595032+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to delete profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4595044+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint.txt TITLE: Get profile by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint.txt # GET /v3/profiles/{profileId} Get profile by ID Retrieves detailed information about a specific sender profile within an organization, including brand and KYC information if a brand has been configured. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Profile retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Sales Team", "email": "team@acme.com", "icon": "https://example.com/sales-icon.png", "description": "Sales department sender profile", "short_name": "SALES", "status": "approved", "created_at": "2026-05-08T14:55:35.462152+00:00", "updated_at": "2026-08-03T14:55:35.4621544+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": true, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "profile", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": null, "billing_contact": null, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-05-08T14:55:35.4621562+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [ { "id": "US", "isMain": false } ], "notes": null } } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4621578+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_014", "message": "Profile not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4621591+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to retrieve profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4621598+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfilesEndpoint.txt TITLE: List profiles in organization ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfilesEndpoint.txt # GET /v3/profiles List profiles in organization Retrieves all sender profiles within an organization, including brand information for each profile. Profiles represent different brands, departments, or use cases within an organization, each with their own messaging configuration. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfilesEndpoint` **Tags:** Profiles ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Profiles retrieved successfully #### application/json ```typescript { success?: boolean, data?: { profiles?: Array<{ id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "profiles": [ { "id": "660e8400-e29b-41d4-a716-446655440001", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Marketing Team", "email": "team@acme.com", "icon": "https://example.com/marketing-icon.png", "description": "Marketing department sender profile", "short_name": "MKT", "status": "approved", "created_at": "2026-05-08T14:55:35.4635432+00:00", "updated_at": "2026-08-03T14:55:35.4635441+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": false, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "profile", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": null, "billing_contact": null, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-05-08T14:55:35.4635448+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [], "notes": null } } } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4635555+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this organization ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4635568+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to retrieve profiles. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4635576+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint.txt TITLE: Update profile settings ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint.txt # PATCH /v3/profiles/{profileId} Update profile settings Updates a profile's configuration and settings. Requires admin role in the organization. Only provided fields will be updated (partial update). ## Brand Management Include the optional `brand` field to create or update the brand associated with this profile. The brand holds KYC and TCR compliance data (legal business info, contact details, messaging vertical). Once a brand has been submitted to TCR it cannot be modified. Setting `inherit_tcr_brand: true` and providing `brand` in the same request is not allowed. ## Payment Details When `billing_model` is `"profile"` or `"profile_and_organization"` you may include a `payment_details` object containing the card number, expiry (MM/YY), CVC, and billing ZIP code. Payment details are **never stored** on our servers and are forwarded directly to the payment processor. Providing `payment_details` when `billing_model` is `"organization"` is not allowed. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, name?: string, icon?: string, description?: string, short_name?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, brand?: { contact?: unknown, business?: { legalName?: string, taxId?: string, taxIdType?: string, entityType?: { }, street?: string, city?: string, state?: string, postalCode?: string, country?: string, url?: string, countryOfRegistration?: string }, compliance?: unknown }, payment_details?: { card_number?: string, expiry?: string, cvc?: string, zip_code?: string } } ``` ## Responses ### 200 Profile updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Sales Team - Updated", "email": "team@acme.com", "icon": "https://example.com/sales-icon.png", "description": "Updated sales department sender profile", "short_name": "SALES", "status": "approved", "created_at": "2026-05-08T14:55:35.4657317+00:00", "updated_at": "2026-08-08T14:55:35.4657325+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": true, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "organization", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": null, "billing_contact": null, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-08-08T14:55:35.4657329+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [ { "id": "US", "isMain": false } ], "notes": null } } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4657352+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "brand": [ "Cannot provide brand data when inherit_tcr_brand is true." ], "payment_details.expiry": [ "payment_details.expiry must be in MM/YY format (e.g. '09/27')" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.465738+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_014", "message": "Profile not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4657384+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to update profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.4657389+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/rate-limits.txt TITLE: Rate Limits ================================================================================ URL: https://docs.sent.dm/llms/reference/api/rate-limits.txt Rate limit values, window semantics, 429 response headers, and per-endpoint limits for the Sent API v3, with the scope rules for shared account pools. # Rate Limits The Sent API v3 implements rate limiting to ensure platform stability and fair access for all users. Rate limits apply per customer account: all API keys for the same account share one rate limit pool, and requests scoped to a profile via the `x-profile-id` header count against the organization's pool. Unauthenticated requests are limited per IP address. For backoff, monitoring, and throttling implementations, see [How to handle Sent API rate limits](/start/guides/handling-rate-limits). --- ## Rate Limit Tiers ### Standard Endpoints Most API endpoints, including message sending (`POST /v3/messages`), use the standard limit: | Tier | Limit | Window | |------|-------|--------| | Standard | 200 requests per minute | Sliding 60-second window | ### Sensitive Endpoints Two endpoints that perform sensitive operations have stricter limits: | Tier | Limit | Window | |------|-------|--------| | Sensitive | 10 requests per minute | Fixed 60-second window | **Sensitive endpoints:** - Webhook secret rotation (`POST /v3/webhooks/{id}/rotate-secret`) - Webhook test delivery (`POST /v3/webhooks/{id}/test`) All other endpoints, including user invitation (`POST /v3/users`) and profile completion (`POST /v3/profiles/{profileId}/complete`), use the standard limit of 200 requests per minute. ### Which Requests Count Requests rejected during authentication (`401`, `403`) do not count against rate limits. All other requests count toward your limit, including those that fail validation (`400`, `422`). --- ## Rate Limit Headers Rate limit headers are sent **only on `429 Too Many Requests` responses**. Successful responses do not carry `X-RateLimit-*` headers, so there is no per-request quota readout. Treat `429` responses as the signal that you have reached the limit. ```http Retry-After: 60 X-RateLimit-Limit: 200 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1705312800 ``` ### Header Reference | Header | Description | Example | |--------|-------------|---------| | `Retry-After` | Seconds until you can retry | `60` | | `X-RateLimit-Limit` | Maximum requests allowed in the window | `200` | | `X-RateLimit-Remaining` | Requests remaining (always `0` on 429) | `0` | | `X-RateLimit-Reset` | Unix timestamp when the current window expires | `1705312800` | Standard limits use a sliding window, so capacity returns gradually as requests age out of the preceding 60 seconds rather than resetting at a fixed time. --- ## 429 Response Body When rate limited, the API returns error code `BUSINESS_002` in the standard error envelope: ```json { "success": false, "status": 429, "error": { "code": "BUSINESS_002", "message": "Rate limit exceeded. Please retry after 60 seconds.", "doc_url": "https://docs.sent.dm/reference/api/rate-limits" }, "meta": { "request_id": "req_abc123", "timestamp": "2024-01-15T10:30:00Z", "version": "v3" } } ``` --- ## Rate Limiting by Endpoint Rather than enumerate every operation, the rule is: | Scope | Limit | Window | |-------|-------|--------| | Webhook secret rotation (`POST /v3/webhooks/{id}/rotate-secret`) | 10/min | Fixed 60-second | | Webhook test delivery (`POST /v3/webhooks/{id}/test`) | 10/min | Fixed 60-second | | **Every other v3 endpoint** | 200/min | Sliding 60-second | The exception list above is exhaustive: the two sensitive endpoints listed here are the only exceptions in v3. Contacts, messages, templates, webhooks, users, profiles, conversations, brands, number lookup, and `GET /v3/me` all use the standard 200/min limit, so a new endpoint is at 200/min unless this page says otherwise. The `/v2` API has its own limits, including a separate daily cap on quick-message. See [Rate limits (v2)](/reference-legacy/api/rate-limits). --- ## Increasing Rate Limits Higher limits are available for legitimate high-volume use cases. Contact [support@sent.dm](mailto:support@sent.dm) with your use case, expected volume, and the rate limit issues you are experiencing. Before requesting an increase, confirm your integration is not wasting its budget on avoidable requests. [How to handle Sent API rate limits](/start/guides/handling-rate-limits) covers caching, throttling, and using [webhooks](/start/webhooks/getting-started) instead of polling. --- ## Related guides - [How to handle Sent API rate limits](/start/guides/handling-rate-limits): exponential backoff, 429 monitoring, client-side throttling, and paced batch processing. - [How to retry Sent API requests safely](/start/guides/retrying-requests-safely): idempotency keys for retried mutations. - [How to handle Sent API errors](/start/guides/handling-api-errors): the error envelope and code-based branching. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/roles-and-permissions.txt TITLE: Roles and Permissions ================================================================================ URL: https://docs.sent.dm/llms/reference/api/roles-and-permissions.txt How Sent's four user roles control access to Sent API v3 operations and dashboard sections, and which checks produce AUTH_004 errors # Roles and Permissions The Sent platform defines four user roles for organizations and Sender Profiles: `owner`, `admin`, `developer`, and `billing`. Roles gate a small set of API operations (profile management and user management) and sections of the [Sent Dashboard](https://app.sent.dm). A request that fails a role check returns HTTP 403 with error code [`AUTH_004`](/reference/api/error-catalog#auth_004-insufficient-permissions). --- ## Role catalog | Role | Assignable via API | Access | |------|--------------------|--------| | `owner` | No | The account that created the organization or profile. The owner is implicit: it has no entry in the user list and satisfies every role check, including the checks that require `admin`. | | `admin` | Yes | Passes all role checks: profile management, user management, and read operations. | | `developer` | Yes | Passes any-role checks: reading profiles and listing users. In the dashboard, has access to development and messaging sections but not user, profile, or billing management. | | `billing` | Yes | Passes any-role checks: reading profiles and listing users. In the dashboard, has access to the Billing section only. | Role values are lowercase in API requests and responses: `admin`, `billing`, `developer`. The `owner` role cannot be requested in an invitation or role update: only the original creator of the organization or profile carries it. --- ## How the API evaluates roles Every API key belongs to an account. Role checks are evaluated against the email address of the account that owns the API key: - The check passes immediately when that email matches the owner email of the organization or profile being accessed. - Otherwise, the check passes when an **active** user with that email exists on the organization or profile and holds an allowed role. - For Sender Profiles, checks cascade to the parent organization: owner or role access at the organization level also grants access to its profiles. - Users with `invited`, `suspended`, or `rejected` status fail role checks. Only `active` users count, including toward the rule that protects the last active `admin`. A failed check returns HTTP 403 with `error.code` `AUTH_004` and a `doc_url` pointing to the [authentication reference](/reference/api/authentication). --- ## API operations gated by role The following v3 operations perform a role check. All other v3 operations (messages, contacts, conversations, templates, campaigns, webhooks, number lookup, and `/v3/me`) require only a valid API key and are not role-gated. | Operation | Required role | 403 `AUTH_004` message | |-----------|--------------|------------------------| | `POST /v3/profiles` | `admin` (on the organization) | "You do not have admin access to this organization" | | `PATCH /v3/profiles/{profileId}` | `admin` | "You do not have admin access to this profile" | | `DELETE /v3/profiles/{profileId}` | `admin` | "You do not have admin access to this profile" | | `GET /v3/profiles/{profileId}` | Any role | "You do not have access to this profile" | | `POST /v3/users` | `admin` | "You do not have admin access to this organization or profile" | | `PATCH /v3/users/{userId}` | `admin` | "You do not have admin access to this organization or profile" | | `DELETE /v3/users/{userId}` | `admin` | "You do not have admin access to this organization or profile" | | `GET /v3/users` | Any role | "You do not have access to this organization or profile" | | `GET /v3/users/{userId}` | Any role | "You do not have access to this organization or profile" | "Any role" means any active user of the organization or profile, regardless of role. The owner passes every check in this table. User management enforces three hard constraints: you cannot change your own role, you cannot remove yourself, and you cannot demote or remove the last active `admin`. Invitations sent with `POST /v3/users` expire after 7 days. Assignable roles for invitations and role updates are `admin`, `billing`, and `developer`. --- ## Dashboard access by role The dashboard gates its sections by the signed-in user's role: | Dashboard section | Roles with access | |-------------------|-------------------| | Overview | All roles | | Templates, Playground, Contacts | `owner`, `admin`, `developer` | | Profiles | `owner`, `admin` | | Number Lookup | `owner`, `admin`, `developer` | | Activities, API Keys, Webhooks | `owner`, `admin`, `developer` | | Compliance | `owner`, `admin` | | Channels, Settings | `owner`, `admin`, `developer` | | Users | `owner`, `admin` | | Billing | `owner`, `admin`, `billing` | --- ## Organization keys and the `x-profile-id` header Every v3 operation accepts the optional `x-profile-id` request header (a profile UUID). It scopes the request to a child profile, so an organization API key can act on behalf of one of its profiles. | Condition | Result | |-----------|--------| | Organization key + UUID of one of its profiles | The request executes as that profile. The response echoes the `X-Profile-Id` header. | | Profile API key + `x-profile-id` | 403 `AUTH_004`: "Profile API keys cannot use x-profile-id. Only organization API keys can act on behalf of profiles." | | Header value is not a valid UUID | 400 `VALIDATION_003` | | Profile does not exist or belongs to another organization | 404 `RESOURCE_013`: "Profile not found." | | Header equals the caller's own account ID | Ignored (no-op) | Role checks on a profile-scoped request are evaluated against the profile, with the usual cascade to the parent organization. --- ## Resolving AUTH_004 `AUTH_004` means the account behind the API key failed the role check for the operation. The messages in the preceding table identify which check failed. The relevant lookups: | Fact needed | Source | |-------------|--------| | Which account the API key belongs to | `GET /v3/me` returns the account, including its `type`: `organization`, `user`, or `profile`. | | Which users exist and their roles | `GET /v3/users` lists all users of the organization or profile with `role` and `status`. Callable by any active role. | | Who can grant access | The owner or an `admin` invites users with `POST /v3/users` and changes roles with `PATCH /v3/users/{userId}`. | An account whose email is neither the owner email nor an active entry in the `GET /v3/users` list has no role, and fails every check in the operations table. --- ## Example Listing users to look up a role: ```bash curl https://api.sent.dm/v3/users \ -H "x-api-key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "users": [ { "id": "880e8400-e29b-41d4-a716-446655440003", "email": "admin@acme.com", "name": "John Admin", "role": "admin", "status": "active", "invited_at": "2026-01-24T15:25:20.114144+00:00", "last_login_at": "2026-07-24T13:25:20.1141456+00:00", "created_at": "2026-01-24T15:25:20.1141459+00:00", "updated_at": "2026-07-23T15:25:20.114146+00:00" }, { "id": "990e8400-e29b-41d4-a716-446655440004", "email": "developer@acme.com", "name": "Jane Developer", "role": "developer", "status": "invited", "invited_at": "2026-07-22T15:25:20.1141463+00:00", "last_login_at": null, "created_at": "2026-07-22T15:25:20.1141464+00:00", "updated_at": "2026-07-22T15:25:20.1141465+00:00" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-07-24T15:25:20.1141776+00:00", "version": "v3" } } ``` A role-check failure returns the standard [error envelope](/reference/api/errors): ```json { "success": false, "status": 403, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-07-24T15:25:20.1199882+00:00", "version": "v3" } } ``` --- ## Related reference - [Authentication](/reference/api/authentication): API keys, environments, and credential management - [Error Catalog](/reference/api/error-catalog): all error codes, including the other `AUTH_*` codes - [Sender Profiles](/start/concepts/sender-profiles): the organization and profile model ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/template-definition.txt TITLE: Template Definition ================================================================================ URL: https://docs.sent.dm/llms/reference/api/template-definition.txt Reference for the Sent template definition JSON: header, body, footer, button, and variable fields, content rules and limits, and template statuses. # Template Definition A template definition is the JSON document that describes a template's content. It is the required `definition` field of the [Create Template](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint) (`POST /v3/templates`) and [Update Template](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint) (`PUT /v3/templates/{id}`) requests. The dashboard template builder displays the same JSON via its **View JSON** action. Top-level request fields use `snake_case` (`submit_for_review`); field names inside `definition` use `camelCase` (`multiChannel`, `variableType`). ## Create template request | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `category` | string | No | `MARKETING`, `UTILITY`, or `AUTHENTICATION`. Auto-detected from the template content when omitted | | `language` | string | No | Language code (for example, `en_US`). Auto-detected from the template content when omitted | | `definition` | object | Yes | The [definition object](#definition-object) | | `creation_source` | string | No | Source label for the template. Default: `from-api` | | `submit_for_review` | boolean | No | `true` submits the template for review immediately after creation. Default: `false`, which saves the template with status `DRAFT` | ## Definition object | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `header` | object | No | [Header object](#header-object) | | `body` | object | Yes | [Body object](#body-object) | | `footer` | object | No | [Footer object](#footer-object) | | `buttons` | array | No | Array of [button objects](#buttons-array) | | `definitionVersion` | string | No | Version of the template definition format | | `authenticationConfig` | object | No | [Authentication configuration](#authentication-configuration), used only by `AUTHENTICATION` templates | ### Channel support | Component | SMS | WhatsApp | RCS | | :--- | :--- | :--- | :--- | | Header | Not supported | Native header | Prepended to message text | | Body | Supported | Supported | Supported | | Footer | Not supported | Native footer | Appended to message text | | Buttons | Not supported | Interactive buttons | First four shown as suggestion chips | ## Header object | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `type` | string | No | Header type: `text`, `image`, `video`, or `document` | | `template` | string | Yes | Header text with optional variable placeholders. Maximum 60 characters | | `variables` | array | No | [Variable objects](#variable-object) used in the header. Maximum 1 | Text headers must not contain newlines, emojis, or formatting markup (`*`, `_`, `~`). `AUTHENTICATION` templates do not use headers; Sent excludes any header from them automatically. ## Body object The body carries channel-specific content. `multiChannel` applies to every channel unless a channel-specific override is present. | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `multiChannel` | object | No | [Body content](#body-content-object) used for all channels without an override | | `sms` | object | No | Body content that overrides `multiChannel` for SMS | | `whatsapp` | object | No | Body content that overrides `multiChannel` for WhatsApp | | `rcs` | object | No | Body content that overrides `multiChannel` for RCS | ### Body content object | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `type` | string | No | Body type: `body` | | `template` | string | Yes | Body text with variable placeholders. Maximum 1024 characters | | `variables` | array | No | [Variable objects](#variable-object) used in the body. IDs must be unique within the body | ## Footer object | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `type` | string | No | Footer type: `text` | | `template` | string | Yes | Footer text. Maximum 60 characters | | `variables` | array | No | Must be empty; footers cannot contain variables | Footers must not contain variables, newlines, emojis, or formatting markup (`*`, `_`, `~`). ## Buttons array A template holds a maximum of 10 buttons. Button IDs must be unique. Per-type limits: maximum 1 `COPY_CODE`, 1 `PHONE_NUMBER`, 2 `URL`, and 10 `QUICK_REPLY` buttons. | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `id` | number | No | Button identifier (1-based index). Must be unique within the template | | `type` | string | Yes | `QUICK_REPLY`, `URL`, `VOICE_CALL`, `PHONE_NUMBER`, or `COPY_CODE` | | `props` | object | Yes | Type-specific [button properties](#button-props-object) | ### Button props object | Field | Type | Applies to | Description | | :--- | :--- | :--- | :--- | | `text` | string | All types | Button label. Maximum 25 characters | | `quickReplyType` | string | `QUICK_REPLY` | `custom` or `pre-configured` | | `urlType` | string | `URL` | `static` or `dynamic` | | `url` | string | `URL` | Destination URL. Maximum 2000 characters | | `variables` | array | `URL` (dynamic) | Exactly 1 [variable object](#variable-object); its placeholder must appear at the end of `url` | | `activeFor` | number | `VOICE_CALL` | Integer greater than 0 | | `countryCode` | string | `PHONE_NUMBER` | Country code (for example, `US`) | | `phoneNumber` | string | `PHONE_NUMBER` | Phone number in international format | | `offerCode` | string | `COPY_CODE` | Code copied to the clipboard on tap | | `otpType` | string | `AUTHENTICATION` OTP buttons | `COPY_CODE` or `ONE_TAP` | ## Variable object Variables use a numbered placeholder syntax in template text: `{{0:variable}}` for text variables and `{{4:link}}` for links, where the number is the variable's `id`. | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `id` | number | No | Sequential ID starting from 0, unique within its section | | `name` | string | Yes | Readable identifier for the variable (for example, `orderNumber`) | | `type` | string | Yes | `variable`, `link`, or `media` | | `props` | object | Yes | [Variable properties](#variable-props-object) | ### Variable props object | Field | Type | Applies to | Description | | :--- | :--- | :--- | :--- | | `variableType` | string | `variable` | Required. `text`, `link`, `image`, or `file` | | `sample` | string | `variable` | Required. Example value shown in previews and used during review | | `regex` | string | `variable` | Optional validation pattern for the variable value | | `url` | string | `link`, `media` | Required. Full HTTP or HTTPS URL | | `shortUrl` | string | `link` | Optional shortened URL | | `alt` | string | `link`, `media` | Alternative text | | `mediaType` | string | `media` | Required. `image`, `video`, or `document` | ## Authentication configuration `AUTHENTICATION` templates accept an `authenticationConfig` object: | Field | Type | Description | | :--- | :--- | :--- | | `addSecurityRecommendation` | boolean | Adds the text "For your security, do not share this code." | | `codeExpirationMinutes` | number | 1–90. When set, adds the footer "This code expires in X minutes." | An `AUTHENTICATION` body must declare exactly one text variable (the code) and must not contain links or emojis. ## Content rules and limits | Rule | Limit | | :--- | :--- | | Body length | 1024 characters per channel body | | Header and footer length | 60 characters each | | Header variables | 1 | | Buttons per template | 10 | | Button label length | 25 characters | | Button URL length | 2000 characters | | Variables in a dynamic URL button | Exactly 1, at the end of the URL | | Emojis in a `MARKETING` body | 10 | Length limits are validated at save time against the stored text, with `{{...}}` placeholders unsubstituted. Counting is encoding-blind: each UTF-16 code unit counts as one character (the behavior of .NET's `string.Length` and JavaScript's `String.prototype.length`), so characters in the Basic Multilingual Plane count as 1 and most emoji count as 2. There is no post-substitution validation; a body within the limit can render into a longer message once variables expand. For how rendered length maps to SMS segments, see [SMS Encoding & Message Length](/start/concepts/sms-encoding-and-length). Body text must additionally satisfy these rules: - Must not start or end with a newline, and must not contain more than two consecutive line breaks or more than four consecutive spaces. - Must contain at least one letter before the first variable and after the last variable; variables cannot be adjacent without text between them. - Word count must be at least (2 × variable count) + 1. ## Template statuses | Status | Meaning | | :--- | :--- | | `DRAFT` | Created but not submitted. Editable and testable; cannot be sent on any channel | | `PENDING` | Submitted for review; awaiting a decision | | `APPROVED` | Approved; available for sending | | `REJECTED` | Declined by the reviewer. A rejected template must be revised and resubmitted before it can send | | `PAUSED` | Paused by Meta; sends against the template are blocked until it is reinstated | Review routing depends on your account: - With a connected WhatsApp Business Account, Meta reviews the template. A Meta `APPROVED` or `PENDING` verdict applies to every channel (SMS, WhatsApp, and RCS); a `REJECTED` verdict applies to the WhatsApp channel only. - Without a connected WhatsApp Business Account, Sent's compliance team reviews the template, and each channel is approved or rejected individually. Approval is tracked per channel. A message sends on a channel only when the template is approved for that channel; sends against unapproved templates return status `BLOCKED`. Status changes are delivered as `templates` [webhook events](/start/webhooks/event-types) carrying the template ID, `channel`, `status`, and `reason`. ## Example A complete `POST /v3/templates` request body with a header, a multi-channel body using text variables and a dynamic link, a footer, and two buttons: ```json { "category": "UTILITY", "language": "en_US", "submit_for_review": false, "definition": { "header": { "type": "text", "template": "Order {{0:variable}}: Delivery Update", "variables": [ { "id": 0, "name": "orderNumber", "type": "variable", "props": { "variableType": "text", "sample": "12345" } } ] }, "body": { "multiChannel": { "type": "body", "template": "Hi {{0:variable}},\nYour Acme order {{1:variable}} has been shipped and is expected to arrive {{2:variable}} between {{3:variable}}.\nYou can track your package or update your delivery preferences at {{4:link}} before the driver arrives.", "variables": [ { "id": 0, "name": "customerName", "type": "variable", "props": { "variableType": "text", "sample": "Lucas" } }, { "id": 1, "name": "orderNumber", "type": "variable", "props": { "variableType": "text", "sample": "#12345" } }, { "id": 2, "name": "arrivalDate", "type": "variable", "props": { "variableType": "text", "sample": "tomorrow (Oct 11)" } }, { "id": 3, "name": "arrivalTime", "type": "variable", "props": { "variableType": "text", "sample": "2 PM – 4 PM" } }, { "id": 4, "name": "orderLink", "type": "link", "props": { "url": "https://example.com", "shortUrl": "", "alt": "Tracking Page" } } ] } }, "footer": { "type": "text", "template": "Thank you for shopping with Acme.", "variables": [] }, "buttons": [ { "id": 1, "type": "URL", "props": { "text": "Track your order", "urlType": "static", "url": "https://www.example.com/track" } }, { "id": 2, "type": "PHONE_NUMBER", "props": { "text": "Acme customer support", "countryCode": "US", "phoneNumber": "+112345678" } } ] } } ``` The endpoint responds with the standard [response envelope](/reference/api/data-models#response-envelope); the created template's fields are documented on the [Create Template endpoint page](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint). ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint.txt TITLE: Create a new template ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint.txt # POST /v3/templates Create a new template Creates a new message template with header, body, footer, and buttons. The template can be submitted for review immediately or saved as draft for later submission. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint` **Tags:** Templates ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, category?: string, language?: string, definition?: unknown, creation_source?: string, submit_for_review?: boolean } ``` ## Responses ### 201 Template created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Welcome Message", "category": "MARKETING", "language": "en_US", "status": "DRAFT", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-08-08T14:55:35.3491584+00:00", "updated_at": "2026-08-08T14:55:35.3491662+00:00", "is_published": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3491791+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Template definition is required", "details": { "definition": [ "Template definition is required" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3491815+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while creating the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.349182+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint.txt TITLE: Delete a template ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint.txt # DELETE /v3/templates/{id} Delete a template Deletes a template by ID. Optionally, you can also delete the template from WhatsApp/Meta by setting delete_from_meta=true. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint` **Tags:** Templates ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Template ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, delete_from_meta?: boolean } ``` ## Responses ### 204 Template deleted successfully ### 400 Invalid template ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid template ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3505094+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.350511+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while deleting the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3505116+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint.txt TITLE: Get template by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint.txt # GET /v3/templates/{id} Get template by ID Retrieves a specific template by its ID. Returns template details including name, category, language, status, and definition. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint` **Tags:** Templates ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Template ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Template retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Welcome Message", "category": "MARKETING", "language": "en_US", "status": "APPROVED", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-07-09T14:55:35.3524388+00:00", "updated_at": "2026-07-24T14:55:35.3524397+00:00", "is_published": true }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.35244+00:00", "version": "v3" } } ``` ### 400 Invalid template ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid template ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3524416+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3524421+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3524426+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint.txt TITLE: Get templates list ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint.txt # GET /v3/templates Get templates list Retrieves a paginated list of message templates for the authenticated customer. Supports filtering by status, category, and search term. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint` **Tags:** Templates ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | Page number (1-indexed) | | `page_size` | `integer` | true | Number of items per page | | `search` | `string` | false | Optional search term for filtering templates | | `status` | `string` | false | Optional status filter: APPROVED, PENDING, REJECTED | | `category` | `string` | false | Optional category filter: MARKETING, UTILITY, AUTHENTICATION | | `is_welcome_playground` | `boolean` | false | Optional filter by welcome playground flag | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Templates retrieved successfully #### application/json ```typescript { success?: boolean, data?: { templates?: Array<{ id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }>, pagination?: unknown }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "templates": [ { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Welcome Message", "category": "MARKETING", "language": "en_US", "status": "APPROVED", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-07-09T14:55:35.3537378+00:00", "updated_at": "2026-07-24T14:55:35.3537382+00:00", "is_published": true } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3537627+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "page_size": [ "Page size must be between 1 and 100" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3537653+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving templates", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3537658+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint.txt TITLE: Update a template ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint.txt # PUT /v3/templates/{id} Update a template Updates an existing template's name, category, language, definition, or submits it for review. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint` **Tags:** Templates ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Template ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, name?: string, category?: string, language?: string, definition?: { header?: { type?: string, template?: string, variables?: Array<{ id?: integer, name: string, type: string, props: { variableType: string, sample: string, regex?: string, url: string, shortUrl?: string, alt?: string, mediaType: string } }> }, body?: unknown, footer?: { type?: string, template?: string, variables?: Array<{ id?: integer, name: string, type: string, props: { variableType: string, sample: string, regex?: string, url: string, shortUrl?: string, alt?: string, mediaType: string } }> }, buttons?: Array<{ id?: integer, type: string, props: unknown }>, definitionVersion?: string, authenticationConfig?: { addSecurityRecommendation?: boolean, codeExpirationMinutes?: integer } }, submit_for_review?: boolean } ``` ## Responses ### 200 Template updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Updated Welcome Message", "category": "MARKETING", "language": "en_US", "status": "DRAFT", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-07-09T14:55:35.3550575+00:00", "updated_at": "2026-08-08T14:55:35.3550579+00:00", "is_published": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3550582+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Category must be one of: MARKETING, UTILITY, AUTHENTICATION", "details": { "category": [ "Category must be one of: MARKETING, UTILITY, AUTHENTICATION" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3550605+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.355061+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while updating the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3550615+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/test-mode.txt TITLE: Sandbox Mode ================================================================================ URL: https://docs.sent.dm/llms/reference/api/test-mode.txt The sandbox request field in the Sent API v3: validation behavior, supported endpoints and their simulated responses, the X-Sandbox header, and troubleshooting. # Sandbox Mode Sandbox mode simulates a mutation request without executing it. When the request body of a supported endpoint contains `"sandbox": true`, the API authenticates and validates the request as usual, then returns a simulated response with sample data instead of performing the operation. For test-suite, CI, and debugging workflows built on sandbox mode, see [Testing with sandbox mode](/start/guides/testing-with-sandbox-mode). --- ## Request Field | Field | Type | Location | Default | |-------|------|----------|---------| | `sandbox` | boolean | JSON request body | `false` | `sandbox` must be the JSON boolean `true`; the string `"true"` is not a valid value. The API reads the field from the request body of the endpoints listed under [Supported Endpoints](#supported-endpoints), never from headers or query parameters. --- ## How It Works For a request with `"sandbox": true`: 1. **Authentication runs.** An invalid or missing API key is rejected with `401`. 2. **Request validation runs.** A malformed payload returns the same `400` errors as a live request. 3. **Execution is skipped.** The database, the send queue, downstream provider APIs, and your balance are all left untouched. 4. **A simulated response is returned.** It uses the same status code, envelope, and schema as a live response, populated with sample data. Identifiers such as `message_id` are newly generated and do not reference stored resources. Sandbox mode does not look up stored resources. A sandbox send referencing a nonexistent template ID still returns `202`; only request-level validation is applied. --- ## Response Headers | Header | Value | |--------|-------| | `X-Sandbox` | `true` when the `sandbox` field was detected. Present on success responses and on `400` validation errors. | --- ## Example ```http POST /v3/messages Content-Type: application/json x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx { "sandbox": true, "to": ["+14155550123"], "channel": ["sms"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "name": "Test User" } } } ``` **Response:** ```http HTTP/1.1 202 Accepted X-Sandbox: true X-Request-Id: req_5f2c1b3a9d8e4f67 Content-Type: application/json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155550123", "channel": "sms", "body": null } ] }, "meta": { "request_id": "req_5f2c1b3a9d8e4f67", "timestamp": "2026-07-25T10:30:00Z", "version": "v3" } } ``` In the simulated response, `template_name` is empty and `body` is `null` because the template is referenced by `id` and sandbox mode performs no template lookup. `message_id` is generated per recipient and does not correspond to a stored message. --- ## Supported Endpoints | Endpoint | Sandbox response | |----------|------------------| | `POST /v3/messages` | `202` with `"status": "QUEUED"` and one simulated recipient per `to` entry per channel. Nothing is queued or sent. | | `POST /v3/contacts` | `201` with a simulated contact echoing `phone_number`. No contact is created. | | `PATCH /v3/contacts/{id}` | `200` with a simulated updated contact. | | `DELETE /v3/contacts/{id}` | `204`. The contact is not deleted. | | `POST /v3/templates` | `201` with a simulated template in `DRAFT` status. | | `PUT /v3/templates/{id}` | `200` with a simulated updated template. | | `DELETE /v3/templates/{id}` | `204`. The template is not deleted. | | `POST /v3/webhooks` | `201` with a simulated webhook and a placeholder signing secret. | | `PUT /v3/webhooks/{id}` | `200` with a simulated updated webhook. | | `PATCH /v3/webhooks/{id}/toggle-status` | `200` with a simulated webhook reflecting the requested `is_active`. | | `POST /v3/webhooks/{id}/rotate-secret` | `200` with a placeholder `signing_secret`. The real secret is unchanged. | | `POST /v3/webhooks/{id}/test` | `200` with a simulated delivery result. No test event is delivered. | | `POST /v3/profiles` | `201` with a simulated profile. | | `PATCH /v3/profiles/{profileId}` | `200` with a simulated updated profile. | | `DELETE /v3/profiles/{profileId}` | `204`. The profile is not deleted. | | `POST /v3/profiles/{profileId}/complete` | `202`. No completion is started. | | `POST /v3/profiles/{profileId}/campaigns` | `201` with a simulated campaign. Nothing is submitted to TCR. | | `PUT /v3/profiles/{profileId}/campaigns/{campaignId}` | `200` with a simulated updated campaign. | | `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}` | `204`. The campaign is not deleted. | | `POST /v3/users` | `201` with a simulated invited user. No invitation is sent. | | `PATCH /v3/users/{userId}` | `200` with a simulated updated user. | | `DELETE /v3/users/{userId}` | `204`. The user is not removed. | GET endpoints do not accept the `sandbox` field. `DELETE /v3/webhooks/{id}` does not support sandbox mode: a request to it always deletes the webhook, even if the body contains `"sandbox": true`. --- ## Sandbox Mode vs Idempotency | Feature | Purpose | Side effects | Response data | |---------|---------|--------------|---------------| | Sandbox mode | Simulate a request | None | Sample data | | Idempotency | Execute a request at most once | On the first request only | Real data, cached for replays | Refer to [Idempotency](/reference/api/idempotency) for key format, expiry, and replay headers. --- ## Troubleshooting | Symptom | Cause | Check | |---------|-------|-------| | Response has no `X-Sandbox` header and the operation executed | The `sandbox` field was not detected | Send `sandbox` as the JSON boolean `true`, not the string `"true"`, in the request body of a supported endpoint. Headers and query parameters are not read. | | `401` | Invalid or missing API key | Sandbox mode does not bypass authentication. The `x-api-key` header must contain a valid key. | | `400` with `X-Sandbox: true` | The payload failed validation | Sandbox requests return the same validation errors as live requests. Correct the fields listed in the error details. | ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint.txt TITLE: Get user by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint.txt # GET /v3/users/{userId} Get user by ID Retrieves detailed information about a specific user in an organization or profile. Requires developer role or higher. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint` **Tags:** Users ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `userId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 User retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "880e8400-e29b-41d4-a716-446655440003", "email": "admin@acme.com", "name": "John Admin", "role": "admin", "status": "active", "invited_at": "2026-02-08T14:55:35.3387837+00:00", "last_login_at": "2026-08-08T12:55:35.3388091+00:00", "created_at": "2026-02-08T14:55:35.3388177+00:00", "updated_at": "2026-08-07T14:55:35.3388247+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3388315+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3388333+00:00", "version": "v3" } } ``` ### 404 User not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_006", "message": "User not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3388337+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3388343+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUsersEndpoint.txt TITLE: List users ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUsersEndpoint.txt # GET /v3/users List users Retrieves all users who have access to the organization or profile identified by the API key, including their roles and status. Shows invited users (pending acceptance) and active users. Requires developer role or higher. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersGetUsersEndpoint` **Tags:** Users ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Users retrieved successfully #### application/json ```typescript { success?: boolean, data?: { users?: Array<{ id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "users": [ { "id": "880e8400-e29b-41d4-a716-446655440003", "email": "admin@acme.com", "name": "John Admin", "role": "admin", "status": "active", "invited_at": "2026-02-08T14:55:35.3406447+00:00", "last_login_at": "2026-08-08T12:55:35.3406455+00:00", "created_at": "2026-02-08T14:55:35.3406457+00:00", "updated_at": "2026-08-07T14:55:35.3406458+00:00" }, { "id": "990e8400-e29b-41d4-a716-446655440004", "email": "developer@acme.com", "name": "Jane Developer", "role": "developer", "status": "invited", "invited_at": "2026-08-06T14:55:35.3406464+00:00", "last_login_at": null, "created_at": "2026-08-06T14:55:35.3406465+00:00", "updated_at": "2026-08-06T14:55:35.3406466+00:00" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3406622+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3406645+00:00", "version": "v3" } } ``` ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3406658+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3406665+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersInviteUserEndpoint.txt TITLE: Invite a user ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersInviteUserEndpoint.txt # POST /v3/users Invite a user Sends an invitation to a user to join the organization or profile with a specific role. Requires admin role. The user will receive an invitation email with a token to accept. Invitation tokens expire after 7 days. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersInviteUserEndpoint` **Tags:** Users ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, email?: string, name?: string, role?: string } ``` ## Responses ### 201 User invited successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "aa0e8400-e29b-41d4-a716-446655440005", "email": "newuser@example.com", "name": "New User", "role": "developer", "status": "invited", "invited_at": "2026-08-08T14:55:35.3439983+00:00", "last_login_at": null, "created_at": "2026-08-08T14:55:35.3439986+00:00", "updated_at": "2026-08-08T14:55:35.3439986+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3439989+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "email": [ "Email must be a valid email address" ], "role": [ "Role must be one of: admin, billing, developer" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3440253+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.344026+00:00", "version": "v3" } } ``` ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3440265+00:00", "version": "v3" } } ``` ### 409 User already exists in organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_007", "message": "User already exists in this organization", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3440286+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3440291+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersRemoveUserEndpoint.txt TITLE: Remove user ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersRemoveUserEndpoint.txt # DELETE /v3/users/{userId} Remove user Removes a user's access to an organization or profile. Requires admin role. You cannot remove yourself or remove the last admin. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersRemoveUserEndpoint` **Tags:** Users ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `userId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean } ``` ## Responses ### 204 User removed successfully ### 400 Invalid request - Cannot remove yourself or last admin #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "You cannot remove yourself from the organization", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3452647+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3452658+00:00", "version": "v3" } } ``` ### 404 User not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_006", "message": "User not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3452662+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3452667+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersUpdateUserEndpoint.txt TITLE: Update user role ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersUpdateUserEndpoint.txt # PATCH /v3/users/{userId} Update user role Updates a user's role in the organization or profile. Requires admin role. You cannot change your own role or demote the last admin. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersUpdateUserEndpoint` **Tags:** Users ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `userId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, role?: string } ``` ## Responses ### 200 User role updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "aa0e8400-e29b-41d4-a716-446655440005", "email": "user@example.com", "name": "User Name", "role": "billing", "status": "active", "invited_at": "2026-07-08T14:55:35.3465799+00:00", "last_login_at": "2026-08-08T09:55:35.3465808+00:00", "created_at": "2026-07-08T14:55:35.3465809+00:00", "updated_at": "2026-08-08T14:55:35.346581+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3465813+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters or cannot modify own role #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "role": [ "Role must be one of: admin, billing, developer" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3466033+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.346604+00:00", "version": "v3" } } ``` ### 404 User not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_006", "message": "User not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3466046+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.346607+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/v2-to-v3.txt TITLE: API v2 to v3 changes ================================================================================ URL: https://docs.sent.dm/llms/reference/api/v2-to-v3.txt How Sent API v3 differs from the legacy v2 API: authentication headers, response envelope, property naming, sandbox mode, idempotency, and endpoint coverage. # API v2 to v3 changes Sent API v3 is the current major version. It replaces the v2 authentication scheme, wraps every response in a standard JSON envelope, and adds capabilities that v2 does not have. The v2 API remains available at `/v2/` paths for existing integrations; the [Legacy API reference](/reference-legacy/api) documents it in full. For step-by-step upgrade instructions, see the [API v2 to v3 migration guide](/start/advanced/migration-v2-v3). ## Changes from v2 | Area | v2 | v3 | Details | |------|-----|-----|---------| | Base path | `/v2/` | `/v3/` | | | Authentication | `x-sender-id` and `x-api-key` headers | `x-api-key` header only | [Authentication](/reference/api/authentication) | | Success responses | No standard envelope | Envelope with `success`, `data`, `error`, and `meta` | [Data models](/reference/api/data-models) | | Error responses | FastEndpoints problem details | `error` object with prefixed `code`, `message`, `details`, and `doc_url` | [Error handling](/reference/api/errors) | | Property naming | camelCase (`messageId`) | snake_case (`message_id`) | | | Sandbox mode | Not available | `sandbox` field in the request body | [Sandbox mode](/reference/api/test-mode) | | Idempotency | Not available | `Idempotency-Key` header on `POST`, `PUT`, and `PATCH` | [Idempotency](/reference/api/idempotency) | | Sender Profiles | Not available | Full CRUD at `/v3/profiles` | [Sender Profiles](/start/concepts/sender-profiles) | | 10DLC brands and campaigns | Not available | Brand registration through Sender Profiles; campaigns at `/v3/profiles/{profileId}/campaigns` | | | User management | Not available | Organization users at `/v3/users` | | | Webhook management | Not available | `/v3/webhooks`, including secret rotation, delivery tests, status toggling, and event-type discovery | | | Webhook event payloads | Flat payload | `event` field with a nested `payload` object | [Webhook event types](/start/webhooks/event-types) | ## Version support The v2 API is a legacy version: it remains supported, and Sent has not announced an end-of-support date. New integrations must use v3. The [changelog](/reference/changelog) records the release history for both versions. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint.txt TITLE: Create a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint.txt # POST /v3/webhooks Create a webhook Creates a new webhook endpoint for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, display_name?: string, endpoint_url?: string, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer } ``` ## Responses ### 201 Webhook created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": "whsec_a1b2c3d4e5f6g7h8i9j0", "is_active": true, "event_types": [ "message", "templates" ], "event_filters": { "message": [ "delivered", "failed" ] }, "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.2606949+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Endpoint URL must be a valid HTTP or HTTPS URL", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.2609334+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while creating the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.2609342+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksDeleteWebhookEndpoint.txt TITLE: Delete a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksDeleteWebhookEndpoint.txt # DELETE /v3/webhooks/{id} Delete a webhook Deletes a webhook for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksDeleteWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 204 Webhook deleted successfully ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3248603+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3248623+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while deleting the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3248631+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint.txt TITLE: Get a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint.txt # GET /v3/webhooks/{id} Get a webhook Retrieves a single webhook by ID for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Webhook retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": null, "is_active": true, "event_types": [ "message", "templates" ], "event_filters": null, "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": "2026-01-20T14:15:00+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3279945+00:00", "version": "v3" } } ``` ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3279965+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.327997+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3279975+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventTypesEndpoint.txt TITLE: Get available webhook event types ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventTypesEndpoint.txt # GET /v3/webhooks/event-types Get available webhook event types Retrieves all available webhook event types that can be subscribed to. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventTypesEndpoint` **Tags:** Webhooks ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Event types retrieved successfully #### application/json ```typescript { success?: boolean, data?: { event_types?: Array<{ name?: string, display_name?: string, description?: string, is_active?: boolean, event_type?: string, sub_types?: Array }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "event_types": [ { "name": "message.sent", "display_name": "Message Sent", "description": "Triggered when a message is successfully sent", "is_active": true, "event_type": null, "sub_types": null }, { "name": "message.delivered", "display_name": "Message Delivered", "description": "Triggered when a message is confirmed delivered", "is_active": true, "event_type": null, "sub_types": null }, { "name": "message.failed", "display_name": "Message Failed", "description": "Triggered when a message delivery fails", "is_active": true, "event_type": null, "sub_types": null } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3307779+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving event types", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3307796+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint.txt TITLE: Get webhook events ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint.txt # GET /v3/webhooks/{id}/events Get webhook events Retrieves a paginated list of delivery events for the specified webhook. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | - | | `page_size` | `integer` | true | - | | `search` | `string` | false | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Webhook events retrieved successfully #### application/json ```typescript { success?: boolean, data?: { events?: Array<{ id?: string, event_type?: string, event_data?: object, delivery_status?: string, http_status_code?: integer, response_body?: string, delivery_attempts?: integer, error_message?: string, created_at?: string, processing_started_at?: string, processing_completed_at?: string }>, pagination?: { page?: integer, page_size?: integer, total_count?: integer, total_pages?: integer, has_more?: boolean, cursors?: { after?: string, before?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "events": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "event_type": "message.delivered", "event_data": { "valueKind": "Undefined" }, "delivery_status": "delivered", "http_status_code": 200, "response_body": null, "delivery_attempts": 1, "error_message": null, "created_at": "2026-01-20T14:30:00+00:00", "processing_started_at": "2026-01-20T14:30:01+00:00", "processing_completed_at": "2026-01-20T14:30:02+00:00" } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3294924+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3294942+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3294947+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving webhook events", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3294952+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint.txt TITLE: Get webhooks list ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint.txt # GET /v3/webhooks Get webhooks list Retrieves a paginated list of webhooks for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint` **Tags:** Webhooks ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | - | | `page_size` | `integer` | true | - | | `search` | `string` | false | - | | `is_active` | `boolean` | false | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Webhooks retrieved successfully #### application/json ```typescript { success?: boolean, data?: { webhooks?: Array<{ id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }>, pagination?: { page?: integer, page_size?: integer, total_count?: integer, total_pages?: integer, has_more?: boolean, cursors?: { after?: string, before?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "webhooks": [ { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": null, "is_active": true, "event_types": [ "message", "templates" ], "event_filters": null, "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3319585+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Page size must be between 1 and 100", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3319599+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving webhooks", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3319605+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksRotateWebhookSecretEndpoint.txt TITLE: Rotate webhook signing secret ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksRotateWebhookSecretEndpoint.txt # POST /v3/webhooks/{id}/rotate-secret Rotate webhook signing secret Generates a new signing secret for the specified webhook. The old secret is immediately invalidated. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksRotateWebhookSecretEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean } ``` ## Responses ### 200 Secret rotated successfully #### application/json ```typescript { success?: boolean, data?: { signing_secret?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "signing_secret": "whsec_n3wS3cr3tK3yG3n3r4t3d" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3331274+00:00", "version": "v3" } } ``` ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3331289+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3331294+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while rotating the webhook secret", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3331299+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint.txt TITLE: Test a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint.txt # POST /v3/webhooks/{id}/test Test a webhook Sends a test event to the specified webhook endpoint to verify connectivity. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, event_type?: string } ``` ## Responses ### 200 Test completed #### application/json ```typescript { success?: boolean, data?: { success?: boolean, message?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "success": true, "message": "Test event delivered successfully" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3343342+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Event type is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3343358+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3343363+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while testing the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3343368+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksToggleWebhookStatusEndpoint.txt TITLE: Toggle webhook status ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksToggleWebhookStatusEndpoint.txt # PATCH /v3/webhooks/{id}/toggle-status Toggle webhook status Activates or deactivates a webhook for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksToggleWebhookStatusEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, is_active?: boolean } ``` ## Responses ### 200 Webhook status updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": null, "is_active": false, "event_types": [ "message", "templates" ], "event_filters": null, "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": "2026-02-01T09:00:00+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3355749+00:00", "version": "v3" } } ``` ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3355763+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3355768+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while toggling the webhook status", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3355773+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksUpdateWebhookEndpoint.txt TITLE: Update a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksUpdateWebhookEndpoint.txt # PUT /v3/webhooks/{id} Update a webhook Updates an existing webhook for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksUpdateWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript { sandbox?: boolean, display_name?: string, endpoint_url?: string, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer } ``` ## Responses ### 200 Webhook updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, event_filters?: object, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Updated Order Notifications", "endpoint_url": "https://example.com/webhooks/orders-v2", "signing_secret": null, "is_active": true, "event_types": [ "message", "templates" ], "event_filters": { "message": [ "delivered", "failed" ] }, "retry_count": 5, "timeout_seconds": 60, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": "2026-02-05T16:45:00+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3369849+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Endpoint URL must be a valid HTTP or HTTPS URL", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3369863+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3369867+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while updating the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-08-08T14:55:35.3369873+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/changelog.txt TITLE: Changelog ================================================================================ URL: https://docs.sent.dm/llms/reference/changelog.txt A chronological record of platform updates, new features, and important changes to Sent's APIs and services # Changelog A chronological record of platform updates, new features, and important changes to Sent's APIs and services. For a summary of what changed between API versions, see [API v2 to v3 changes](/reference/api/v2-to-v3). --- ## 2026 ### July 2026 #### 10DLC: Messaging Volume Moved to Campaign (Breaking Change) Expected messaging volume is now a per-campaign field, not a brand-level attribute. Volume drives the TCR tier classification (low-volume vs. standard) and the monthly campaign fee. These are per-campaign concerns: a brand can have multiple campaigns at different tiers. - **Removed**: `expected_messaging_volume` from `BrandComplianceInfo` (brand request body) and `BrandComplianceResponse` (brand response). The field is no longer accepted or returned on any brand endpoint. - **Removed**: The dedicated update-brand-volume endpoint has been deleted. There is no replacement at the brand level. - **Added**: `volume` field on campaign create (`POST /v3/profiles/{profileId}/campaigns`), update (`PUT /v3/profiles/{profileId}/campaigns/{campaignId}`), and get (`GET /v3/profiles/{profileId}/campaigns`) endpoints. The value is a numeric string representing expected daily message volume (for example `"1500"`). Values strictly below `2000` register the campaign at the low-volume tier (capped at 2,000 messages/day, lower monthly fee); `2000` and above register as standard. - **Changed**: When updating a campaign's `volume` causes a tier change, the billing delta for the current window is applied immediately rather than being deferred to the next billing sweep. **Migration:** Move `expected_messaging_volume` from brand requests to the `volume` field on each campaign request. | Before | After | |---|---| | `BrandComplianceInfo.expected_messaging_volume` (brand request body) | `CampaignData.volume` (campaign create/update body) | | `BrandComplianceResponse.expected_messaging_volume` (brand response) | `TcrCampaignWithUseCasesResponse.volume` (campaign response) | | Single value shared across all campaigns under a brand | Set independently per campaign | #### Message Status Model: New Values Three new `MessageStatus` values have been introduced to distinguish policy-driven suppression from delivery failures: - **Added**: `FILTERED`: message suppressed by a DENY routing rule before reaching a carrier. Policy-driven and expected. Does **not** count against your deliverability rate. The specific policy that stopped the send is recorded internally and is not returned in API responses or webhook payloads. Webhook event: `message.filtered`. - **Added**: `BLOCKED`: message gated before send evaluation due to an account-level condition such as insufficient balance, an unmet onboarding entitlement, or a template that is not approved for sending. Does **not** count against your deliverability rate. The specific condition is recorded internally and is not returned in API responses or webhook payloads. Webhook event: `message.blocked`. Sends from a suspended account are rejected at the API edge with `403` and code `BUSINESS_014` and never create a message record, so they do not produce this status. - **Added**: `SCHEDULED`: message queued for future delivery (curfew window or scheduled send). Not a terminal state; it transitions to `ROUTED` once the window opens. The release time is not exposed as an API field. Webhook event: `message.scheduled`. **Migration note:** Prior to this release, all non-delivered outcomes were surfaced as `FAILED`. If your integration filters on `FAILED` to catch all unsent messages, also handle `FILTERED` and `BLOCKED` to maintain full coverage. `FILTERED` and `BLOCKED` are excluded from the deliverability rate denominator, so only `FAILED` counts against the rate. | Old behavior | New behavior | |---|---| | `FAILED` covered all non-delivered outcomes | `FAILED` = downstream delivery attempt that failed (carrier/network) | | No equivalent | `FILTERED` = suppressed by a DENY rule, which is policy-driven rather than a failure | | No equivalent | `BLOCKED` = gated by an account condition before send | | No equivalent | `SCHEDULED` = deferred to a future window (transient, not terminal) | #### Message Scheduling & Quiet Hours - **Added**: Messages destined for a mobile network with quiet-hours restrictions are now automatically deferred (status `SCHEDULED`) instead of being sent or failed during the restricted window, and released once it ends. - **Fixed**: Quiet-hours windows that span midnight are now evaluated correctly. #### Delivery Reliability - **Added**: SMS messages that fail delivery on their original channel are now automatically retried on an alternate channel. This automatic fallback now also covers WhatsApp messages that can't be delivered. - **Fixed**: A delivery receipt from a carrier could occasionally be matched to the wrong send attempt after a message was retried across channels, leaving its status stuck. Delivery status now updates correctly in this case. - **Fixed**: `message.received` webhook events were not firing for some inbound WhatsApp and SMS auto-reply messages, and for messages sent to shared/ported numbers or redelivered inbound messages. These now fire correctly. #### Compliance & Consent - **Added**: WhatsApp contacts who send a keyword command (`STOP`, `START`, `HELP`) now automatically receive a reply, and a `STOP` reply updates the contact's `opt_out` status as returned by `GET /v3/contacts/{id}`. - **Updated**: Stricter validation now applies to `AUTHENTICATION`-category WhatsApp template bodies for the SMS channel, and for link/URL variables. #### API Reliability & Security - **Fixed**: Sending a free-form (non-template) message with `channel` omitted (auto-detect) could bypass the conversation-window gate that normally restricts free-form SMS/RCS sends to an active conversation. Auto-detect sends are now gated the same as explicit-channel sends. - **Fixed**: Authentication-failure lockouts (`429`) are now scoped to the specific API key rather than the caller's IP address, so a customer sharing an IP with another tenant is no longer at risk of being locked out by unrelated failed attempts. - **Fixed**: `DELETE /v3/templates/{id}` could fail for templates with channel-status or WhatsApp update-history records; deletion now cleans these up first. #### Dashboard - **Added**: Sign in and sign up with Google, GitHub, or Facebook, in addition to email and password. - **Added**: A new bulk-messaging tab in the Playground lets you upload a CSV of recipients and send to the whole list at once, with upfront validation of phone number formats. - **Added**: Message activity now shows a segment count (for example, "3 parts") for multi-part SMS messages. --- ### June 2026 #### Conversations - **Added**: New `GET /v3/conversations` and `GET /v3/conversations/{id}` endpoints to list a customer's messages grouped by conversation, with pagination. #### WhatsApp Messaging - **Added**: `POST /v3/messages` now accepts a plain-text `text` field as an alternative to `template`, for free-form WhatsApp messages within the 24-hour customer-service window. Sending free-form text outside that window now returns a dedicated `WHATSAPP_TEMPLATE_REQUIRED` error. - **Added**: WhatsApp templates can now be approved and sent per channel independently. A template pending or rejected on one channel no longer blocks sending on a channel where it's approved. Sending on a channel where the template isn't active now returns a dedicated `BUSINESS_012` error. - **Fixed**: Templates approved without a connected WhatsApp Business Account (for example, compliance-approved for SMS only) can now actually be sent, instead of being blocked for lacking a WhatsApp template ID. #### Billing Accuracy - **Fixed**: Multi-part SMS messages (over the 160-character GSM segment limit) are now billed per segment rather than as a single message. - **Fixed**: WhatsApp messages billed directly by Meta to your WhatsApp Business Account are now only charged the difference against Sent's own price for that message, preventing double-charging. - **Updated**: The recurring per-contact messaging charge now runs on a rolling 28-day cycle from the last charge, rather than resetting on the first of each calendar month. - **Fixed**: Corrected per-network SMS cost resolution to always use the actual matching rate for a destination. #### 10DLC Compliance - **Added**: `GET` campaign responses now include a `pricing` object (the resolved recurring campaign fee) and a flag indicating whether the one-time submission fee has been charged. - **Updated**: 10DLC campaign submission and recurring maintenance fees are now billed on each campaign's own activation anniversary, rather than a shared calendar cycle. - **Fixed**: Campaign submission fees that were missed on a brand resubmission are now correctly backfilled and charged. - **Fixed**: Campaign activation now reliably registers the connected phone number with the carrier, so an approved campaign is actually enabled for sending. #### Reliability - **Fixed**: WhatsApp Business Account health is now determined the same way across onboarding, Meta webhooks, and send-time checks, reducing inconsistent "WhatsApp degraded" errors. - **Fixed**: SMS delivery status is no longer marked `DELIVERED` as soon as a carrier accepts a message for transit. It now stays in transit until an actual delivery or failure signal arrives. - **Fixed**: Contact opt-out (`STOP`) handling is now unified onto a single consent record, so a contact's opt-out status is consistent regardless of which channel they opted out on. `HELP`/`STOP` compliance auto-replies are no longer blocked by a contact's own opt-out status. - **Fixed**: A deny routing rule with no fallback configured is now a hard block, instead of occasionally falling through to another route and sending anyway. #### Dashboard - **Added**: Sub-account (Sender Profile) SMS configuration and pricing can now automatically inherit from the parent organization's defaults. - **Added**: Self-service auto-recharge: enable auto-charge to automatically top up your balance from a saved card when it drops below a chosen threshold. - **Added**: A restructured Brand Registration (10DLC) wizard for compliance onboarding. - **Added**: Dedicated Sender Identity requests now support uploading required registration documents directly, with document-type status shown in the flow. - **Added**: Campaign details now offer an AI-assisted quality check and rewrite of your campaign messaging. --- ### May 2026 #### Pre-Send Consent Gate **Superseded**: Consent-blocked messages are finalized as `FILTERED`, not `FAILED`, and surface on the `message.filtered` webhook. `POST /v3/messages` no longer rejects a send over recipient opt-out state: the all-recipients-opted-out case below is also accepted with `202` and blocked asynchronously per message. See the [error catalog](/reference/api/error-catalog). - **Added**: New `ERR_CONSENT_BLOCKED` send-time error code. Every outbound message now runs through a pre-routing consent check that refuses sends to recipients with `opt_out = true` or whose phone is on the customer's phone-channel suppression list. Blocked messages are finalized as `FAILED` before any provider call is made, so no carrier charge applies. Inspect `description` on the message detail or in `GET /v3/messages/{id}/activities` to see the reason. - **Updated**: `BUSINESS_004` is now scoped to the **all-recipients-opted-out** case. When *some* recipients are still reachable, the API accepts the batch and only the blocked recipients fail asynchronously with `ERR_CONSENT_BLOCKED`. See [API Errors](/reference/api/errors) and the [error catalog](/reference/api/error-catalog). #### Inbound Message Webhooks - **Fixed**: `message.received` webhook events now fire correctly for inbound messages across all channels. Previously, subscribing to `message.received` produced no deliveries despite the event appearing in the subscription list. The webhook notification was only sent for outbound status changes. All inbound messages persisted since this release trigger a `message.received` event to your webhook endpoint. - **Updated**: `message.received` payload `channel` field now reflects the actual inbound channel (`sms` or `whatsapp`). WhatsApp replies fire the same `message.received` event shape as SMS replies. - **Fixed**: Inbound WhatsApp replies now reliably resolve to the correct account when a number has been used across multiple WhatsApp Business Accounts. #### Profile Completion API - **Updated**: `POST /v3/profiles/{profileId}/complete` now returns distinct typed responses: - **200**: Profile was already in a completed state. Response body: `{success: true, data: {status: "completed", message: "..."}}`. - **202**: Completion accepted and running in the background. Response body: `{success: true, data: {message: "..."}}`. The provided webhook URL is called when processing finishes. - Previously both paths returned an opaque `object` type, which made it difficult to distinguish synchronous completion from asynchronous processing in generated SDK clients. #### API Schema - **Updated**: `event_data` and `metadata` fields on webhook event and message objects are now typed as open JSON objects in the OpenAPI spec. This resolves ambiguity in generated SDK clients where these fields previously appeared as untyped empty schemas. Runtime behaviour is unchanged. #### Webhook Payload Standardization - **Updated**: Standardized the message webhook payload shape. The event-type field is now named `event` (was `sub_type`). Inbound message payloads: `from`/`to` renamed to `inbound_number`/`outbound_number`, the `provider` field was removed, and `updated_at` was added. Outbound message payloads gained `updated_at` and `template_name` fields. - **Updated**: `message.sent` now reflects successful submission to the upstream provider; provider-side acknowledgement signals that previously could report as `message.sent` now resolve to `message.delivered`, so you see one clear status after submission (a later `message.failed` can still follow). `message.read` webhooks now cover WhatsApp and RCS (previously WhatsApp only), and are never emitted for SMS. #### WhatsApp Template Reliability - **Fixed**: Several classes of WhatsApp template were silently stuck and never actually submitted to Meta for approval: default compliance auto-reply templates (`STOP`/`START`/`HELP`), templates copied from the template library, and templates in a bulk submission with an empty name or unresolved language. These now submit correctly, and previously stuck templates were backfilled. - **Added**: WhatsApp template URL buttons now support a dynamic variable in the button URL, for per-recipient personalization. #### Compliance & Reliability - **Fixed**: Inbound SMS opt-in (`START`) keywords are now only honored when the receiving number is currently assigned to your account, preventing consent from being recorded incorrectly after a number is reassigned or released. - **Fixed**: A cross-provider configuration bug could let one SMS provider's delivery/webhook settings bleed into another's, affecting delivery status and webhook routing for some numbers. Resolved. - **Fixed**: Messages that failed before a channel was selected now report the actual (or auto-routed) channel instead of the placeholder value `unknown`. --- ### April 2026 #### Opt-In / Opt-Out Keyword Management - **Added**: Per-account keyword management for SMS opt-in and opt-out flows. You can now configure custom keywords (beyond the standard STOP/START/HELP) that trigger automatic replies and consent state changes. Contacts who send an opt-out keyword are automatically suppressed from future messages. - **Added**: Inbound keyword matches now trigger configurable auto-replies, billed as standard outbound messages. #### Inbound Message Processing - **Added**: Inbound messages from contacts are now persisted and fully tracked across all supported channels. Each inbound message appears in your message history with `direction: "INBOUND"` and `status: "RECEIVED"`. - **Added**: Inbound messages generate a `message.received` webhook event. Subscribe to this event to receive real-time notifications when contacts reply to your messages. See [webhook event types](/start/webhooks/event-types) for payload details. - **Added**: The `from` field is now captured on all message activities, so you can see the sender's phone number throughout the full message lifecycle. #### Webhook Event Filtering - **Added**: Granular webhook event filtering via the `event_filters` field. When creating or updating a webhook, you can now specify which message sub-types to receive per event type (for example, only `delivered` and `failed`) instead of subscribing to all status transitions. This reduces webhook volume for high-throughput integrations. --- ### March 2026 #### Template Approval Enforcement - **Added**: Messages using WhatsApp templates that have not been approved by Meta are now blocked before dispatch, with a clear error response. Previously, unapproved templates could reach the provider and produce a delayed failure. Approved templates continue to send without any change. #### OTP Delivery via WhatsApp - **Added**: OTP codes can now be delivered via WhatsApp. The platform waits for a delivery confirmation before reporting success, and automatically falls back to SMS if WhatsApp delivery is not confirmed within the timeout window. No API changes required. Channel selection follows your normal routing configuration. #### Account Suspension - **Added**: Accounts flagged as suspended are blocked from sending messages and receive a clear `account_suspended` error response on all API calls. This applies to both the messaging API and webhook delivery. #### Error Documentation Links - **Updated**: All API error responses now include a `doc_url` field pointing to the relevant documentation page for that specific error code. This makes it easier to diagnose and resolve integration issues without searching the docs manually. #### Message Metadata - **Added**: Messages now carry a `metadata` object that captures origin context (for example, the API endpoint that created the message). This field is returned in message detail and activity responses as an open JSON object. --- ### February 2026 #### Per-Customer Pricing - **Added**: Two billing models are now supported: **Active Contact** (charged per unique contact reached in a billing period) and **Per Message** (charged per message sent). Your account's billing model is configured at account setup. Charges are deducted automatically after each successful send. #### Number Provisioning - **Added**: Phone numbers associated with your account are now visible via the API. Use `GET /v3/numbers/lookup/{phoneNumber}` to retrieve carrier and capability information for any number. #### Playground Message Endpoint - **Added**: A dedicated endpoint for sending test messages during onboarding, without requiring a fully provisioned profile. Useful for verifying end-to-end message delivery before going live. --- ### January 2026 #### Organizations & Sender Profiles (Multi-Tenancy) - **Added**: Full multi-tenancy support via Organizations and Sender Profiles. An Organization is the top-level account; Sender Profiles are sub-accounts that can inherit or isolate contacts, templates, brands, and campaigns from the parent organization. - **Added**: Profile CRUD: create, list, retrieve, update, and delete Sender Profiles via the API. - **Added**: `POST /v3/profiles/{id}/complete`: trigger the compliance setup workflow for a profile (TCR registration, WhatsApp connection). - **Added**: Contact and template inheritance: profiles can be configured to share contacts and templates with the parent organization (`inheritContacts`, `inheritTemplates`), or to maintain their own isolated sets. - **Added**: Template sharing: templates created at the organization level can be shared with child profiles without duplication. #### User Management - **Added**: Full user management for your organization: - `GET /v3/users` / `GET /v3/users/{id}`: list and retrieve users - `POST /v3/users`: invite a user by email with a specified role - `PATCH /v3/users/{id}`: update a user's role - `DELETE /v3/users/{id}`: remove a user from the organization - **Added**: Role-based access: `OWNER`, `ADMIN`, and `MEMBER` roles with appropriate permission scopes. Owners have implicit `ADMIN` access across all endpoints. - **Added**: User invitation emails with organization and profile context included in the invitation link. #### WhatsApp Template Synchronization - **Added**: WhatsApp templates are now automatically synchronized from your connected WhatsApp Business Account. Template status updates (approved, rejected, paused) are reflected in real time via webhook-driven sync. No manual re-import needed. - **Added**: Template soft-deletion: when a WhatsApp Business Account is disconnected or deleted, all associated templates are soft-deleted rather than permanently removed, preserving message history. - **Added**: AUTHENTICATION template category support, including OTP and COPY_CODE button types for one-time password flows. #### Error Codes - **Added**: Error code system for message sending failures. Each failure now includes a machine-readable `error_code` field in the message activity, making it straightforward to programmatically distinguish temporary failures (retry) from permanent ones (suppress). --- ## 2025 No customer-facing API changes shipped in 2025. Development during the year went into the platform capabilities released in early 2026, including multi-tenancy (Organizations and Sender Profiles), user management, WhatsApp template synchronization, and the error code system. See the [January 2026](#january-2026) entries for details. --- ## 2024 ### December 2024 #### API v3 General Availability - **Added**: Sent API v3 is now the recommended version for all integrations - **Added**: Full API documentation for v3 endpoints - **Added**: New getting started guide for v3 ### November 2024 #### Webhook Endpoints - **Added**: `GET /v3/webhooks/event-types` endpoint to list available event types - **Added**: `POST /v3/webhooks/{id}/test` endpoint to test webhook delivery - **Added**: `POST /v3/webhooks/{id}/rotate-secret` endpoint for security rotation - **Added**: `PATCH /v3/webhooks/{id}/toggle-status` endpoint to toggle webhook status #### User Management - **Added**: `GET /v3/users` and `GET /v3/users/{id}` for user listing and details - **Added**: `POST /v3/users` to invite users to your organization - **Added**: `PATCH /v3/users/{id}` to update user roles - **Added**: `DELETE /v3/users/{id}` to remove users ### October 2024 #### 10DLC Compliance (Brands & Campaigns) **Superseded**: The standalone `/v3/brands` endpoints below no longer exist. Brand registration now flows through [Sender Profiles](/start/concepts/sender-profiles), and campaigns are managed under `/v3/profiles/{profileId}/campaigns`. - **Added**: Brand registration endpoints for 10DLC compliance (superseded; brand registration now flows through Sender Profiles) - `POST /v3/brands` - Create brand - `GET /v3/brands` - List brands - `PUT /v3/brands/{id}` - Update brand - `DELETE /v3/brands/{id}` - Delete brand - **Added**: Campaign management endpoints (paths since moved under Sender Profiles) - `POST /v3/brands/{id}/campaigns` - Create campaign (now `POST /v3/profiles/{profileId}/campaigns`) - `GET /v3/brands/{id}/campaigns` - List campaigns (now `GET /v3/profiles/{profileId}/campaigns`) - `PUT /v3/brands/{id}/campaigns/{id}` - Update campaign (now `PUT /v3/profiles/{profileId}/campaigns/{campaignId}`) - `DELETE /v3/brands/{id}/campaigns/{id}` - Delete campaign (now `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`) #### Profile Management - **Added**: Full profile CRUD operations - `POST /v3/profiles` - Create profile - `GET /v3/profiles` - List profiles - `GET /v3/profiles/{id}` - Get profile details - `PATCH /v3/profiles/{id}` - Update profile - `DELETE /v3/profiles/{id}` - Delete profile - **Added**: `POST /v3/profiles/{id}/complete` for profile setup completion ### September 2024 #### API v3 Beta Release - **Added**: Core messaging endpoints - `POST /v3/messages` - Send messages - `GET /v3/messages/{id}` - Get message status - `GET /v3/messages/{id}/activities` - Get message activities - **Added**: Contact management - `GET /v3/contacts` - List contacts - `POST /v3/contacts` - Create contact - `GET /v3/contacts/{id}` - Get contact - `PATCH /v3/contacts/{id}` - Update contact - `DELETE /v3/contacts/{id}` - Delete contact - **Added**: Template management - `GET /v3/templates` - List templates - `POST /v3/templates` - Create template - `GET /v3/templates/{id}` - Get template - `PUT /v3/templates/{id}` - Update template - `DELETE /v3/templates/{id}` - Delete template - **Added**: Webhook management - `GET /v3/webhooks` - List webhooks - `POST /v3/webhooks` - Create webhook - `GET /v3/webhooks/{id}` - Get webhook - `PUT /v3/webhooks/{id}` - Update webhook - `DELETE /v3/webhooks/{id}` - Delete webhook - `GET /v3/webhooks/{id}/events` - List webhook events - **Added**: Number lookup - `GET /v3/numbers/lookup/{phoneNumber}` - Look up phone number information - **Added**: Account information - `GET /v3/me` - Get authenticated account details #### New Features - **Added**: Sandbox mode (`sandbox: true`) for all mutation endpoints - **Added**: Idempotency key support via `Idempotency-Key` header - **Added**: Consistent JSON response envelope (`success`, `data`, `error`, `meta`) - **Added**: Standardized error codes with documentation URLs - **Added**: snake_case property naming convention - **Added**: Rate limiting with detailed response headers --- ## 2023 ### Legacy API v2 The v2 API remains fully supported for existing integrations. All v2 endpoints continue to operate at `/v2/` paths. Key v2 endpoints: - `POST /v2/messages/contact` - Send message to contact - `POST /v2/messages/phone` - Send message to phone number - `GET /v2/contacts` - List contacts - `GET /v2/templates` - List templates See [Legacy API Reference](/reference-legacy/api) for complete v2 documentation. --- ## Deprecation Notices ### API v2 - **Status**: Legacy (still supported) - **Recommendation**: New integrations should use v3 - **End of Support**: No end date announced --- Subscribe to our [API Status](https://status.sent.dm) page for real-time updates on API changes and service status. --- ## Feedback Have suggestions for API improvements? Contact [support@sent.dm](mailto:support@sent.dm). --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/channel-routing.txt TITLE: Channel Routing and Fallback Behavior in the Sent API ================================================================================ URL: https://docs.sent.dm/llms/reference/channel-routing.txt Accepted channel values for POST /v3/messages, the resolution and fallback rules behind the default sent auto-detect channel, and what channel auto means. # Channel Routing and Fallback Behavior in the Sent API Channel routing selects the channel (`sms`, `whatsapp`, or `rcs`) and the underlying provider for every message accepted by `POST /v3/messages`. This page defines the accepted `channel` values, the resolution rules behind the default `sent` auto-detect channel, fallback behavior, and where the resolved channel appears in API responses, message records, and webhooks. For the design rationale behind auto-detection, see [Unified Messaging Intelligence](/start/concepts/unified-messaging). For what each channel supports, see [Channels](/start/concepts/channels). --- ## Channel Values `POST /v3/messages` accepts `channel` as an array of strings. Allowed values: | Value | Behavior | |-------|----------| | `sent` | Auto-detect: routing selects the channel per recipient at send time. Default when `channel` is omitted. | | `sms` | Pins the message to SMS. | | `whatsapp` | Pins the message to WhatsApp. | | `rcs` | Pins the message to RCS. | - Omitting `channel` or sending an empty array is equivalent to `["sent"]`. - Any other value fails request validation and the request is rejected with a `400` error. - The array is a broadcast list, not a fallback priority list. Each entry creates a separate message per recipient: `"channel": ["whatsapp", "sms"]` with two recipients creates four messages. There is no `fallback` field; fallback is a property of routing, described below. - The `202` response echoes the request per message in `recipients[].channel`. Auto-detect entries appear as `"sent"`. ```bash curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "to": ["+14155551234"], "template": { "name": "order_confirmation", "parameters": { "name": "John" } } }' ``` ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "sent", "body": "Hi John, your order has been confirmed." } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-07-25T10:30:00Z", "version": "v3" } } ``` --- ## How Auto-Detect Resolves a Channel A message on the `sent` channel is matched against routing rules at send time, after the request has already been accepted with `202`. Routing rules are created and maintained by the platform from your channel setup: - Connecting a WhatsApp Business account creates an account-scoped WhatsApp route. - Activating a phone number for a destination country creates account-scoped SMS routes for that country. - Global rules (scoped to no account) apply to all accounts. A send never matches another account's rules. A rule matches when every dimension it constrains matches the send; a dimension the rule leaves unset matches anything. Rules can constrain the recipient (country, number prefix, exact number, carrier, number type, ported state), the sender, the template (id, name, category), the channel, and whether the send is international. Matching rules form an ordered candidate list. The ordering criteria, applied in sequence: 1. Rules pinned to the exact recipient number rank ahead of all others. 2. Rules scoped to your account rank ahead of global rules. 3. Match specificity: the share of the send's attributes the rule explicitly matches, highest first. 4. Rule priority, highest first. 5. Longer (more specific) recipient number prefix. 6. Older rule first. Rules that are inactive, deleted, or expired are excluded, as are rules whose own minimum match threshold is not met. Candidates on a channel where the message's template carries an explicit non-approved review status (for example rejected, pending, or paused) are dropped; a channel with no per-channel review recorded is not blocked. The first candidate wins: the message transitions to `ROUTED`, a `message.routed` webhook fires, and the send dispatches on the winning route. The remaining candidates stay available as fallback routes. Auto-detect applies no fixed channel preference order. The winner is the best-matching rule under the preceding criteria; whether that is an SMS or a WhatsApp route depends on which routes exist for your account and the recipient. If no rule matches, the message fails without a channel; see [Messages with channel auto](#messages-with-channel-auto). --- ## Pinned Channels A pinned entry (`sms`, `whatsapp`, or `rcs`) restricts matching to routes on that channel. Rules without a channel constraint still match and resolve to the pinned channel. - A pinned send never falls back to a different channel. Fallback between routes on the same channel (for example, an SMS provider hop) remains possible when the rule permits it. - If no route exists on the pinned channel, the message fails with no route matched, even when another channel could deliver to the recipient. --- ## Fallback ### At Send Time Fallback walks the resolved candidate list in order: - The winning candidate is attempted first. Each attempted route is recorded as a delivery attempt on the same message. - If submission to the provider fails and the candidate's rule allows fallback, the next candidate is attempted, repeating down the list. - A DENY rule that allows fallback passes the send to the next candidate. - A DENY rule that does not allow fallback ends the send as `FILTERED` with the internal code `ERR_ROUTE_DENIED`, as does a candidate list in which every route is denied. The message record carries the denied route's channel. ### After a Delivery Failure A message that a provider accepted and later reported as failed can re-enter routing: - A terminal `FAILED` delivery receipt triggers a reroute only when its error signals a route or carrier failure another route might overcome: undeliverable by this route, provider service unavailable, provider timeout, or a transport error. All other failures stay `FAILED`. - A WhatsApp message that Meta accepts and then reports as failed for recipient-side reasons is rerouted the same way, and a recipient-scoped rule records that WhatsApp is not deliverable for that number. This is the path behind WhatsApp-to-SMS fallback on auto-detect sends. - A reroute excludes every route already attempted and re-runs the send pipeline on the same message id, so the `QUEUED` and `ROUTED` transitions and their webhooks fire again. - A message attempts at most 3 distinct routes (channel and provider pairs) across its initial send and all reroutes. - Consent gates re-apply on every reroute; an opted-out recipient never receives a rerouted message. --- ## Where the Resolved Channel Appears Internally, a message that has not yet resolved a route carries the placeholder channel `auto`. Public surfaces expose it as follows: | Surface | Before a route is chosen | After a route is attempted | |---------|--------------------------|----------------------------| | `POST /v3/messages` response, `recipients[].channel` | `sent` for auto-detect entries, otherwise the pinned channel | Not updated; the response is returned at accept time | | `message.queued`, `message.routed`, and `message.scheduled` webhooks, `payload.channel` | `sent` for auto-detect sends, otherwise the pinned channel | Fire again on a reroute | | Terminal webhooks (`message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.filtered`, `message.blocked`), `payload.channel` | `auto` when the send ends before any route was chosen | The channel of the attempted route | | `GET /v3/messages/{id}`, `channel` field | `auto` for auto-detect sends, otherwise the pinned channel | The channel of the attempted route | Webhook payload shapes are documented in [Webhook event types](/start/webhooks/event-types); the status lifecycle is documented in [Message status tracking](/start/guides/message-status-tracking). --- ## Messages With Channel `auto` A terminal message whose `channel` is `auto` ended before routing chose a channel. This occurs only on auto-detect sends; a pinned send keeps its pinned channel in every state. | Terminal status | Cause | Internal error code | |-----------------|-------|---------------------| | `FAILED` | No routing rule matched the send: no route covers this recipient for your account | `ERR_NO_ROUTE_MATCHED` | | `FAILED` | Required template variables missing or invalid (pre-routing validation) | `ERR_TEMPLATE_PARAMS_INVALID` | | `FILTERED` | Pre-routing consent gate: the recipient opted out or the number is on your suppression list | `ERR_CONSENT_BLOCKED` | | `BLOCKED` | Account precondition gate: insufficient balance, an onboarding quota, or an unapproved template | Varies | A `FAILED` message on channel `auto` with no route matched means the recipient is not covered by any channel that is set up for your account. Routes exist once the corresponding channel setup is complete; see [Channel setup](/start/quickstart/channel-setup). Persistent delivery problems are covered in [Messages not delivered](/troubleshooting/messages-not-delivered). The internal `ERR_*` code is recorded on the message but is not included in API responses or webhook payloads. See [Error handling](/reference/api/errors) for the full list of send-time error codes, or contact [support@sent.dm](mailto:support@sent.dm) with the message id for the exact reason. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/glossary.txt TITLE: Glossary of Sent Messaging, Compliance, and API Terms ================================================================================ URL: https://docs.sent.dm/llms/reference/glossary.txt Definitions of the messaging, compliance, and API terms used across the Sent platform, from channels, contacts, and Sender Profiles to templates and webhooks # Glossary of Sent Messaging, Compliance, and API Terms Definitions of key terms and concepts used throughout the Sent platform and documentation. ## 0–9 ### 10DLC 10-Digit Long Code, the registration framework required for A2P (Application-to-Person) SMS messaging in the United States. Businesses sending automated SMS to US numbers must register a brand and campaign with The Campaign Registry (TCR); Sent submits this registration on your behalf when you complete compliance onboarding. ## A ### API Key Authentication token passed in the `x-api-key` header on every API request. Keys prefixed `sk_live_` are production keys; keys prefixed `sk_test_` are sandbox keys. ### Authentication Message WhatsApp message category for OTP codes, verification messages, and login confirmations. Usually has the lowest cost per message. ### Available Channels Comma-separated list of messaging channels (for example, `"sms,whatsapp,rcs"`) that can reach a specific contact based on their phone number validation. ## B ### Balance Account credit used to pay for messages. When a send exceeds the available balance, the API still accepts the request and the message is finalized with the `BLOCKED` status. ### Brand The business identity registered with The Campaign Registry for 10DLC compliance, grouping contact, business, and compliance (KYC) information. Each Sender Profile has a brand, created with the profile or inherited from the organization. ### Branded Sender (RCS) The visual identity displayed in the recipient's messaging app for RCS messages, including company logo, verified business name, and a checkmark. Unlike SMS, which shows a raw phone number, RCS branded sender makes the business immediately recognizable. ### Business Account WhatsApp Business Account (WABA) identifier required for WhatsApp messaging integration. ## C ### Campaign A registered 10DLC messaging use case linked to a brand. A campaign declares one or more use cases with sample messages, plus opt-in, opt-out, and help keywords and compliance links. Campaigns are managed per Sender Profile through the `/v3/profiles/{profileId}/campaigns` endpoints. ### Carousel Card (RCS) An RCS-standard message format consisting of up to 10 rich cards that the recipient can scroll through horizontally. The 10-card cap is set by the RCS/RBM platform specification, not by Sent. Sent does not currently send carousel messages. ### Channel Communication method used to deliver messages: - **SMS**: Traditional text messaging - **WhatsApp**: Business messaging via Meta's WhatsApp Business API - **RCS**: Rich Communication Services, carrier-native rich messaging for Android (Google Messages), no separate app required ### Consent A contact's permission to receive your messages, tracked per contact through the `opt_out` field. See Opt-Out. ### Contact A phone number recipient in your messaging system, including validation data, available channels, and formatting information. ### Contact ID Unique UUID identifier for a contact within a customer's account. ### Customer The account entity that owns an API key. Depending on account type, a customer is an organization (has child Sender Profiles), a standalone user account, or a Sender Profile; `GET /v3/me` returns the account associated with your key. Rate limits and idempotency keys are scoped per customer. ### Customer ID Unique UUID identifier for a customer account. ## D ### Default Channel Preferred messaging channel for a contact, determined by phone number validation and regional settings. ## E ### E.164 Format International phone number standard (for example, `+1234567890`) used as the canonical format throughout the platform. ### Endpoint API route handling a specific operation (for example, `POST /v3/messages`, `GET /v3/contacts/{id}`). ## F ### Formatting Phone number representations in various formats: - **E.164**: `+1234567890` (canonical) - **International**: `+1 234-567-890` (human-readable) - **National**: `(234) 567-890` (country-specific) - **RFC 3966**: `tel:+1-234-567-890` (URI format) ## I ### Idempotency Key Optional `Idempotency-Key` header accepted by POST, PUT, and PATCH requests to guarantee at-most-once execution. A key is 1–255 alphanumeric characters, hyphens, or underscores; duplicate requests with the same key return the original response. Keys are scoped per customer and responses are cached for 24 hours. ## K ### KYC (Know Your Customer) Compliance data including business information, contact details, and use case descriptions required for messaging services. Collected as part of brand registration. ## M ### Marketing Message WhatsApp message category for promotional content and campaigns. Typically has higher cost per message and requires opt-in. ### Message Activity Event log recording a message's processing steps, including channel selection, sending status, and delivery confirmation. Available via `GET /v3/messages/{id}/activities`. ### Message Body Final rendered content of a message after template variable substitution. ### Message Status The current state of a message, also called delivery status. Outbound statuses are `QUEUED`, `ROUTED`, `SENT`, `DELIVERED`, `READ` (WhatsApp and RCS only), `FAILED`, `SCHEDULED` (deferred until the recipient's quiet hours end), `FILTERED` (suppressed by a policy gate such as an opt-out or a routing deny rule), and `BLOCKED` (gated by an account precondition such as insufficient balance). Inbound messages have the status `RECEIVED`. ## O ### Opt-Out A contact's withdrawal of consent to receive messages. Opt-out is per contact, not per channel, and is exposed as the `opt_out` field on the contact. Contacts opt out by replying with keywords such as `STOP` (`START` opts back in, `HELP` requests assistance). Sends to opted-out recipients are accepted by the API but suppressed and finalized with the `FILTERED` status. ### Organization The top-level account in Sent's multi-tenancy model. An organization owns billing, compliance, and shared resources, and contains one or more Sender Profiles that can inherit those resources. Organization API keys can act on behalf of a child profile by passing the `x-profile-id` header. ## P ### Pagination Structure of list responses. Paginated endpoints accept the `page` query parameter (1-indexed, default `1`) and the `page_size` query parameter (default `20`, range 1–100), and return a `pagination` object with `page`, `page_size`, `total_count`, `total_pages`, `has_more`, and optional `cursors` (`after`, `before`). ### Phone Number Validation Process determining if a phone number is valid, possible, and identifying its type (mobile, fixed-line, etc.). ### Pricing Cost structure for messages varying by channel, region, and message category (especially for WhatsApp). ## R ### RCS (Rich Communication Services) A carrier-native messaging protocol that enables rich media, interactive buttons, and branded Sender Profiles delivered over carrier networks directly within the Android default messaging app (Google Messages). RCS requires no separate app installation by the recipient. Like WhatsApp, RCS requires account verification, a Sender Profile, and template approval before sending. Sent automatically falls back to SMS for recipients where RCS is unavailable. API channel value: `"rcs"`. ### RCS Agent The branded sender identity used for RCS messaging. Each business has an RCS Agent that displays a company logo, verified business name, and brand color in the recipient's Google Messages inbox, instead of a phone number like SMS. Setting up an RCS Agent requires a one-time approval process with carriers; it cannot be self-activated. ### Region Code ISO 3166-1 alpha-2 country code (for example, `"US"`, `"CA"`) used for pricing and channel availability. ### Rich Card (RCS) An RCS-standard message format that combines a title, description, image or video, and action buttons into a single structured card. Sent does not currently send rich cards; RCS messages sent through Sent render as text with suggestion chips. ## S ### Sandbox Mode A request mode that validates a mutation without executing it. Set `sandbox: true` in the body of any POST, PUT, or DELETE request: authentication and validation run normally, but the request stops before execution, leaving the database, the send queue, and downstream provider APIs untouched. Sandbox responses return realistic sample data and include the `X-Sandbox: true` header. ### Segment One unit of SMS transmission. A message longer than a single SMS (160 GSM-7 or 70 Unicode characters) is split into segments of up to 153 (GSM-7) or 67 (Unicode) characters that the recipient's phone reassembles into one message. SMS is billed per segment. ### Sender ID The identity displayed as the message sender on the recipient's device. Depending on the destination country, this is an alphanumeric sender ID (for example, `MyBrand`), a phone number, or a short code. The US and Canada require a numeric sender; alphanumeric sender IDs do not support replies. ### Sender Profile A messaging identity within an organization. Each Sender Profile has its own API credentials, sending identity, and configuration, and can inherit resources (contacts, templates, WhatsApp Business Account, brand, and campaigns) from the organization or keep dedicated ones. Managed via the `/v3/profiles` endpoints. ### SMS Short Message Service - traditional text messaging. ### Suggestion Chip (RCS) An interactive button displayed below an RCS message that the recipient can tap. Types include quick reply (sends a predefined text back), open URL (launches a browser), and dial number (initiates a call). Suggestion chips are not available on SMS. ### System User Access Token Long-lived access token created in Meta Business Manager for a System User; used to link a WhatsApp Business Account with direct credentials. ## T ### Template Reusable message format supporting variable substitution, with a body of up to 1,024 characters: - **WhatsApp templates**: Must be approved by Meta before use; support rich media and interactive elements - **SMS templates**: Can be used immediately without approval ### Template Category Classification for message templates: - **AUTHENTICATION**: OTP and verification messages - **MARKETING**: Promotional content requiring opt-in - **UTILITY**: Transactional messages and confirmations ### Template Status Template approval state: `PENDING`, `APPROVED`, or `REJECTED`. ### Transaction Financial record tracking balance changes from message costs, payments, or refunds. ## U ### Unified Messaging Intelligence Sent's system for automatically selecting the optimal messaging channel based on contact validation, availability, and cost. ### Utility Message WhatsApp message category for transactional content like order confirmations and delivery notifications. ### UUID (Universally Unique Identifier) 128-bit identifier format used for all entity IDs throughout the platform. ## V ### Validation Process of verifying phone number format, reachability, and available messaging channels. ### Variable Substitution Process of replacing placeholder values (for example, `{{1}}`, `{{name}}`) in message templates with actual content. ## W ### Webhook HTTPS endpoint you register to receive real-time event notifications, such as message status changes and inbound messages. Each delivery is a POST request signed with HMAC-SHA256; verify the `X-Webhook-Signature` header using the endpoint's signing secret before trusting the payload. ### WhatsApp Business API Meta's business messaging platform integrated for professional messaging capabilities. ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/two-way-messaging.txt TITLE: Two-Way Messaging Keywords, Channel Support, and Endpoints ================================================================================ URL: https://docs.sent.dm/llms/reference/two-way-messaging.txt Default and custom keyword actions, exact-match rules, two-way channel support for SMS, RCS, and WhatsApp, and the GET /v3/conversations history endpoints. # Two-Way Messaging Keywords, Channel Support, and Endpoints This page documents the fixed facts of two-way messaging: the default keyword set and matching rules, per-channel feature support, SMS provider limitations, and the conversation history endpoints. For how the inbound pipeline works and why it behaves this way, see [Two-Way Conversations](/start/guides/two-way-conversations). For webhook handler code, see [Receiving Inbound Messages](/start/webhooks/receiving-inbound-messages). --- ## Default Keywords Sent seeds ten default keywords for every account, implementing CTIA/TCPA keyword handling: | Keyword | Action | |---------|--------| | `STOP` | Opts the contact out | | `CANCEL` | Opts the contact out | | `UNSUBSCRIBE` | Opts the contact out | | `QUIT` | Opts the contact out | | `END` | Opts the contact out | | `START` | Opts the contact back in | | `UNSTOP` | Opts the contact back in | | `SUBSCRIBE` | Opts the contact back in | | `HELP` | Sends the configured help auto-reply | | `INFO` | Sends the configured help auto-reply | Opt-out flips the `opt_out` flag on the contact record; the flag is contact-level and channel-agnostic, so a keyword received on any channel suppresses the contact on all channels. ## Matching Rules - The entire trimmed message body must equal a keyword. `"STOP"` matches; `"Please stop messaging me"` does not. - Case is ignored: `stop`, `Stop`, and `STOP` all match. - Partial phrases, sentences, and free-form opt-out requests never match. - Matching runs against your full keyword list (defaults plus custom keywords) on every inbound message on a two-way capable channel. ## Custom Keywords Custom keywords are managed in the dashboard under **Compliance → Opt Keywords**. Each keyword carries one of three actions: **Opt Out**, **Opt In**, or **Help**. Custom keywords follow the same exact-match rules as the defaults, and must be a single exact token, not a sentence. Default keywords are seeded automatically for every account and remain active alongside any custom keywords you add. ## RCS STOP Chip Sent appends a STOP suggestion chip to every outbound RCS message. - Taps on the built-in STOP chip carry opt-out postback data. The consent engine processes the postback directly and sets the contact's `opt_out` flag; keyword matching is not involved. - Chip taps are delivered to your webhook as `message.received` events with the chip's reply text in `payload.text`. There is no separate event type for chip taps. - Typed RCS replies go through standard keyword matching. --- ## Channel Support | Feature | SMS | RCS | WhatsApp | |---------|:---:|:---:|:--------:| | Inbound stored as `RECEIVED` | Yes* | Yes | Yes | | Reply from same sender routing | Yes* | Yes | Yes | | Keyword detection (STOP/START/HELP) | Yes* | Yes | Yes | | Auto-reply | Yes* | Yes | Yes (free-form text within the 24-hour window) | | Opt-out engine | Yes* | Yes | Yes | \* SMS requires an MO-capable provider and a number type with an inbound path; see [SMS Provider Support](#sms-provider-support). WhatsApp auto-replies are delivered as free-form session text inside the 24-hour conversation window, and the configured STOP/START/HELP template must be approved for the reply to send. ## SMS Provider Support | Configuration | Two-way support | |---------------|-----------------| | Long codes and short codes on a route with an inbound (MO) path | Supported | | Alphanumeric sender IDs | Not supported. Send-only; contacts cannot reply, and any attempt is dropped at the carrier level | | SMPP connections without an inbound path | Not supported. Delivery receipts only; no inbound path | On an alphanumeric sender ID or an SMPP provider, inbound keywords (STOP, START, HELP) never reach Sent and are not processed. --- ## Conversation History Endpoints Two account-scoped endpoints return message history. Both return the standard v3 envelope with a `data.messages` array and a `data.pagination` object; see [Data Models](/reference/api/data-models) for the envelope and pagination shapes. ### List Conversation Messages ```bash GET /v3/conversations?page=1&page_size=20 ``` Returns a paginated list of your messages across all conversations, ordered by created date, most recent first. | Parameter | In | Type | Required | Description | |-----------|----|------|----------|-------------| | `page` | query | integer | Yes | Page number. Must be greater than 0. | | `page_size` | query | integer | Yes | Results per page. Must be between 1 and 100. | | `x-profile-id` | header | string (UUID) | No | Scopes the request to a child profile. Organization API keys only. | ### List Messages for a Conversation ```bash GET /v3/conversations/{id}?page=1&page_size=20 ``` Returns a paginated list of the messages in a single conversation, ordered by created date, most recent first. Accepts the same `page`, `page_size`, and `x-profile-id` parameters. | Parameter | In | Type | Required | Description | |-----------|----|------|----------|-------------| | `id` | path | string (UUID) | Yes | The conversation id. See [Conversation IDs](#conversation-ids). | | `page` | query | integer | Yes | Page number. Must be greater than 0. | | `page_size` | query | integer | Yes | Results per page. Must be between 1 and 100. | | `x-profile-id` | header | string (UUID) | No | Scopes the request to a child profile. Organization API keys only. | ```json { "success": true, "data": { "messages": [ { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "customer_id": "41681f0a-15b4-45ab-afca-d9af35b4d3a1", "contact_id": "14b278db-6db8-448f-a5db-8d6ce46a3451", "phone": "+1234567890", "phone_international": "+1 234-567-890", "region_code": "US", "template_id": null, "template_name": null, "template_category": null, "channel": "sms", "message_body": null, "status": "DELIVERED", "direction": "OUTBOUND", "created_at": "2026-07-24T15:20:20.4343024+00:00", "price": 0.0075, "active_contact_price": 0.0, "events": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-07-24T15:25:20.4359817+00:00", "version": "v3" } } ``` ### Message Fields | Field | Type | Description | |-------|------|-------------| | `id` | string (UUID) | Message id | | `customer_id` | string (UUID) | Your account's customer id | | `contact_id` | string (UUID) | The contact the message was exchanged with | | `phone` | string | Contact number in E.164 format | | `phone_international` | string | Contact number in international display format | | `region_code` | string | Two-letter region code of the contact number | | `template_id` | string (UUID) \| null | Template used for the message, when one was used | | `template_name` | string \| null | Name of the template used | | `template_category` | string \| null | Category of the template used | | `channel` | string | `sms`, `whatsapp`, or `rcs` | | `message_body` | object \| null | Message body content | | `status` | string | Message status, for example `DELIVERED` or `RECEIVED` | | `direction` | string | `OUTBOUND` or `INBOUND` | | `created_at` | string | ISO 8601 creation timestamp | | `price` | number \| null | Provider cost for the message | | `active_contact_price` | number \| null | Active-contact price component | | `events` | array \| null | Always `null` on the conversation endpoints; per-message status history is served by [message activities](/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint) | ### Errors | Code | Meaning | Condition | |------|---------|-----------| | `400` | Invalid request parameters | `page` is less than 1, or `page_size` is outside 1–100 | | `401` | Unauthorized | Missing or invalid API key | ### Conversation IDs A conversation id is a deterministic RFC 4122 v5 UUID computed from your account's customer id and the contact id. It identifies one continuous thread between your account and a contact across every channel; it does not depend on the sender number or channel. | Input | Value | |-------|-------| | Namespace | `9f4e6a2c-0b1d-4c3e-8a5f-2d7e6c1b0a99` | | Name | `{customer_id}:{contact_id}`, lowercase canonical UUIDs, customer id first | | Algorithm | SHA-1 name-based UUID (version 5), equivalent to PostgreSQL's `uuid_generate_v5(namespace, name)` | The same pair always yields the same id, so you can compute a conversation id from the `customer_id` and `contact_id` fields of any message without an extra lookup. The API does not return conversation ids as a separate field. ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/best-practices.txt TITLE: SDK Best Practices ================================================================================ URL: https://docs.sent.dm/llms/sdks/best-practices.txt Production-ready patterns for using Sent SDKs. Error handling, retries, testing, and webhook security. # SDK Best Practices Build production-ready messaging integrations with Sent SDKs. This guide covers patterns for error handling, retries, testing, and security that apply across all languages. These practices are recommended for production deployments. For quick prototyping, the basic SDK usage shown in the [language-specific SDK guides](/sdks) is sufficient. ## Error Handling Strategy ### Handle Errors by Type Most SDKs throw typed exceptions for errors; the Go SDK returns a typed error value instead. Handle the specific type: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log(`Sent: ${response.data.recipients[0].message_id}`); } catch (error) { if (error instanceof SentDm.BadRequestError) { console.error('Invalid request:', error.message); } else if (error instanceof SentDm.RateLimitError) { console.error('Rate limited. Retry after:', error.headers.get('retry-after')); } else if (error instanceof SentDm.AuthenticationError) { console.error('Invalid API key'); } else if (error instanceof SentDm.APIError) { console.error(`API Error ${error.status}:`, error.message); } else { console.error('Unexpected error:', error); } } ``` ```python import sent_dm from sent_dm import Sent client = Sent() try: response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(f"Sent: {response.data.recipients[0].message_id}") except sent_dm.BadRequestError as e: print(f"Invalid request: {str(e)}") except sent_dm.RateLimitError as e: print(f"Rate limited. Retry after: {e.response.headers.get('retry-after')}") except sent_dm.AuthenticationError as e: print("Invalid API key") except sent_dm.APIStatusError as e: print(f"API Error {e.status_code}: {str(e)}") except sent_dm.APIError as e: print(f"Unexpected error: {e}") ``` ```java import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; import dm.sent.errors.BadRequestException; import dm.sent.errors.RateLimitException; import dm.sent.errors.UnauthorizedException; import dm.sent.errors.SentException; SentClient client = SentOkHttpClient.fromEnv(); try { MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("welcome") .build()) .build(); var response = client.messages().send(params); System.out.println("Sent: " + response.data().recipients().get().get(0).messageId()); } catch (BadRequestException e) { System.err.println("Invalid request: " + e.getMessage()); } catch (RateLimitException e) { System.err.println("Rate limited: " + e.getMessage()); } catch (UnauthorizedException e) { System.err.println("Invalid API key"); } catch (SentException e) { System.err.println("API Error: " + e.getMessage()); } ``` ```csharp using Sentdm; using Sentdm.Models.Messages; SentClient client = new(); try { MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new Template { ID = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome" } }; var response = await client.Messages.Send(parameters); Console.WriteLine($"Sent: {response.Data.Recipients[0].MessageID}"); } catch (SentBadRequestException e) { Console.WriteLine($"Invalid request: {e.Message}"); } catch (SentRateLimitException e) { Console.WriteLine("Rate limited"); } catch (SentUnauthorizedException e) { Console.WriteLine("Invalid API key"); } catch (SentApiException e) { Console.WriteLine($"API Error: {e.Message}"); } ``` ```go import ( "errors" "github.com/sentdm/sent-dm-go" ) client := sentdm.NewClient() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, }) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 400: fmt.Println("Invalid request:", apiErr.Error()) case 429: fmt.Println("Rate limited") case 401: fmt.Println("Invalid API key") default: fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Error()) } } else { fmt.Println("Network error:", err) } } else { fmt.Printf("Sent: %s\n", response.Data.Recipients[0].MessageID) } ``` ### Handle Specific Error Codes Different errors require different handling strategies: ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log(`Sent: ${response.data.recipients[0].message_id}`); } catch (error) { if (error instanceof SentDm.RateLimitError) { // Back off and retry const retryAfter = parseInt(error.headers.get('retry-after') || '60', 10); await delay(retryAfter * 1000); return retry(); } if (error instanceof SentDm.BadRequestError) { // Check specific error code if available in message if (error.message.includes('TEMPLATE_001')) { // Template not found - check template ID console.error('Template not found'); } else if (error.message.includes('INSUFFICIENT_CREDITS')) { // Alert operations team await alertOpsTeam('Account balance low'); } } throw error; } ``` ```python import time import sent_dm def send_welcome(client): try: return client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) except sent_dm.RateLimitError as e: # Back off and retry retry_after = int(e.response.headers.get('retry-after', 60)) time.sleep(retry_after) return retry() except sent_dm.BadRequestError as e: # Check specific error in message if 'TEMPLATE_001' in str(e): print("Template not found") elif 'INSUFFICIENT_CREDITS' in str(e): alert_ops_team('Account balance low') raise ``` ## Retry Strategies ### Use Built-in Retries All SDKs have built-in retry logic with exponential backoff: ```typescript // Configure max retries (default is 2) const client = new SentDm({ maxRetries: 3 // Retry up to 3 times }); // Or per-request await client.messages.send(params, { maxRetries: 5 }); ``` ```python from sent_dm import Sent # Configure max retries (default is 2) client = Sent(max_retries=3) # Or per-request client.with_options(max_retries=5).messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) ``` ```go // Configure max retries (default is 2) client := sentdm.NewClient( option.WithMaxRetries(3), ) ``` ```java SentClient client = SentOkHttpClient.builder() .maxRetries(3) .build(); ``` ### Custom Retry Logic For app-specific retry logic: ```typescript async function sendWithRetry( client: SentDm, params: SentDm.MessageSendParams, maxRetries = 3 ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await client.messages.send(params); } catch (error) { // Don't retry client errors (4xx) except rate limit if (error instanceof SentDm.BadRequestError && !(error instanceof SentDm.RateLimitError)) { throw error; } // Don't retry on last attempt if (attempt === maxRetries) { throw error; } // Exponential backoff: 1s, 2s, 4s const delayMs = Math.min(1000 * Math.pow(2, attempt), 10000); await sleep(delayMs); } } throw new Error('Unreachable'); } ``` ```python import time from sent_dm import Sent def send_with_retry( client: Sent, to: list[str], template: dict, max_retries: int = 3 ): for attempt in range(max_retries + 1): try: return client.messages.send( to=to, template=template ) except Exception as e: # Don't retry client errors (4xx) except rate limit if hasattr(e, 'status_code') and e.status_code == 429: pass # Will retry elif hasattr(e, 'status_code') and 400 <= e.status_code < 500: raise # Don't retry on last attempt if attempt == max_retries: raise # Exponential backoff: 1s, 2s, 4s delay_ms = min(1000 * (2 ** attempt), 10000) time.sleep(delay_ms / 1000) ``` ## Testing Strategies ### Use Sandbox Mode Always use `sandbox` in development and CI/CD: All SDKs support the `sandbox` parameter to validate requests without sending real messages. Use this in development and CI/CD environments. Refer to [Testing with SDKs](/sdks/testing) for complete unit, integration, and CI/CD strategies. ```typescript // Enable sandbox mode to validate without sending const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, sandbox: true // Validates but doesn't send }); ``` ```python # Enable sandbox mode to validate without sending response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" }, sandbox=True # Validates but doesn't send ) ``` ```go // Enable sandbox mode to validate without sending response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, Sandbox: sentdm.Bool(true), // Validates but doesn't send }) ``` ```java // Enable sandbox mode to validate without sending MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("welcome") .build()) .sandbox(true) // Validates but doesn't send .build(); var response = client.messages().send(params); ``` ```csharp // Enable sandbox mode to validate without sending MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new Template { ID = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome" }, Sandbox = true // Validates but doesn't send }; var response = await client.Messages.Send(parameters); ``` ```php // Enable sandbox mode to validate without sending $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], sandbox: true // Validates but doesn't send ); ``` ### Mock the SDK in Unit Tests Don't make real API calls in unit tests: ```typescript // jest.mock example jest.mock('@sentdm/sentdm', () => ({ default: jest.fn().mockImplementation(() => ({ messages: { send: jest.fn().mockResolvedValue({ data: { status: 'QUEUED', recipients: [{ message_id: 'msg_123', to: '+1234567890', channel: 'sms' }] } }) } })) })); // Test your business logic it('should send welcome message on signup', async () => { await userService.signup({ phone: '+1234567890' }); // Verify the SDK method was called expect(mockSend).toHaveBeenCalledWith({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); }); ``` ```python # pytest example with unittest.mock from unittest.mock import Mock, patch def test_send_welcome_message(): mock_client = Mock() mock_client.messages.send.return_value = Mock( data=Mock( status='QUEUED', recipients=[Mock(message_id='msg_123', to='+1234567890', channel='sms')] ) ) with patch('myapp.services.SentDm', return_value=mock_client): user_service.signup(phone='+1234567890') mock_client.messages.send.assert_called_with( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) ``` ## RCS Channel Considerations When integrating RCS into your app, keep these SDK-level patterns in mind: - **Always pair RCS with SMS fallback**: Use `channel: ["rcs", "sms"]` (or the equivalent in your SDK language) rather than `["rcs"]` alone. This ensures delivery when RCS is unavailable for a recipient without extra code in your app. - **Subscribe to `message.received`**: Inbound RCS replies (including taps on the built-in STOP chip in Google Messages) are delivered as [`message.received` webhook events](/start/webhooks/event-types). Add `"received"` to your `event_filters.message` array and inspect `payload.text` for opt-out keywords (`STOP`, `UNSUBSCRIBE`, `END`, `CANCEL`, `QUIT`). The platform also automatically flips the contact's `opt_out` flag when one is received. - **Handle `READ` status for both WhatsApp and RCS**: The `message.read` event fires for both channels. Check the `channel` field in the payload before applying channel-specific business logic (for example, closing a service ticket on WhatsApp read vs. logging engagement on RCS read). - **No number provisioning needed**: RCS does not require managing phone numbers in your SDK integration. The RCS Agent is configured in the Sent dashboard during onboarding. ## Language-Specific API Patterns Each SDK follows the idiomatic patterns of its language. Here are the key differences: ### Method Naming Conventions | Language | Method Style | Example | |----------|--------------|---------| | **TypeScript** | camelCase | `client.messages.send()` | | **Python** | snake_case | `client.messages.send()` | | **Go** | PascalCase (exported) | `client.Messages.Send()` | | **Java** | camelCase | `client.messages().send()` | | **C#** | PascalCase | `client.Messages.Send()` | | **PHP** | camelCase | `$client->messages->send()` | | **Ruby** | snake_case | `client.messages.send_` | ### Error Handling Patterns **Exception-based (TypeScript, Python, Java, C#, PHP, Ruby):** - SDKs throw exceptions for API errors - Catch specific exception types for different handling - Network errors throw connection exceptions **Error return (Go):** - Go returns errors as second value - Check `err != nil` before using response - API errors are typed errors ## Webhook Security ### Always Verify Signatures Never process webhooks without verifying the signature. Unsigned webhooks could be spoofed. SDKs do not ship a `verifySignature` helper. Verify the HMAC signature manually. The signing secret (from the Sent Dashboard) has a `whsec_` prefix; strip it and base64-decode the remainder to get the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the step-by-step verification procedure. ```typescript import { createHmac, timingSafeEqual } from 'crypto'; app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body as Buffer; const webhookId = req.headers['x-webhook-id'] as string; const timestamp = req.headers['x-webhook-timestamp'] as string; const signature = req.headers['x-webhook-signature'] as string; // Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" const secret = process.env.SENT_DM_WEBHOOK_SECRET!; // "whsec_abc123..." const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signed = `${webhookId}.${timestamp}.${payload.toString('utf8')}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signed).digest('base64'); if (!signature || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).json({ error: 'Invalid signature' }); } // Process webhook const event = JSON.parse(payload.toString()); processWebhook(event); res.json({ received: true }); }); ``` ```python import hmac import hashlib import base64 @app.route('/webhooks/sent', methods=['POST']) def webhook(): webhook_id = request.headers.get('X-Webhook-ID', '') timestamp = request.headers.get('X-Webhook-Timestamp', '') signature = request.headers.get('X-Webhook-Signature', '') payload = request.get_data() # raw bytes — do NOT parse JSON first # Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" secret = os.environ['SENT_DM_WEBHOOK_SECRET'] # "whsec_abc123..." key_bytes = base64.b64decode(secret.removeprefix('whsec_')) signed = f"{webhook_id}.{timestamp}.{payload.decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest() expected = 'v1,' + base64.b64encode(digest).decode() if not hmac.compare_digest(signature, expected): return jsonify({'error': 'Invalid signature'}), 401 # Process webhook event = json.loads(payload) process_webhook(event) return jsonify({'received': True}) ``` ```go func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) webhookID := r.Header.Get("X-Webhook-ID") timestamp := r.Header.Get("X-Webhook-Timestamp") signature := r.Header.Get("X-Webhook-Signature") // Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" raw := os.Getenv("SENT_DM_WEBHOOK_SECRET") // "whsec_abc123..." keyStr := strings.TrimPrefix(raw, "whsec_") keyBytes, _ := base64.StdEncoding.DecodeString(keyStr) signed := webhookID + "." + timestamp + "." + string(body) mac := hmac.New(sha256.New, keyBytes) mac.Write([]byte(signed)) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(signature), []byte(expected)) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // Process webhook processWebhook(body) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"received": true}) } ``` ### Handle Webhooks Idempotently Webhook events may be delivered multiple times. Handle them idempotently: ```typescript async function processWebhook(event: WebhookEvent) { const eventId = event.meta?.request_id || event.id; // Check if already processed const existing = await db.webhookEvents.findUnique({ where: { eventId } }); if (existing) { console.log(`Event ${eventId} already processed`); return { received: true }; } // Process event await handleEvent(event); // Record as processed await db.webhookEvents.create({ data: { eventId, type: event.type, processedAt: new Date() } }); return { received: true }; } ``` ### Respond Quickly Webhook handlers should respond quickly to avoid timeouts: ```typescript app.post('/webhooks/sent', async (req, res) => { // Verify signature if (!isValidSignature(req)) { return res.status(401).end(); } // Respond immediately res.status(200).json({ received: true }); // Process asynchronously processWebhook(req.body).catch(err => { logger.error('Webhook processing failed', err); }); }); ``` ## Performance Optimization ### Reuse Client Instances Create one client instance and reuse it across your application. Don't create a new client for each request. ```typescript // config/sent.ts import SentDm from '@sentdm/sentdm'; // Create once export const sentClient = new SentDm(); // Use everywhere import { sentClient } from './config/sent'; export async function sendMessage(params) { return sentClient.messages.send(params); } ``` ```python # config/sent.py from sent_dm import Sent # Create once sent_client = Sent() # Use everywhere from config.sent import sent_client def send_message(phone_number, template_id): return sent_client.messages.send( to=[phone_number], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": template_id } ) ``` ```go // config/sent.go var Client *sentdm.Client func init() { Client = sentdm.NewClient() } // Use everywhere import "myapp/config" func sendMessage(params sentdm.MessageSendParams) error { _, err := config.Client.Messages.Send(ctx, params) return err } ``` ```java // config/SentConfig.java @Configuration public class SentConfig { @Bean public SentClient sentClient() { return SentOkHttpClient.fromEnv(); } } // Use via injection @Service public class MessageService { private final SentClient client; public MessageService(SentClient client) { this.client = client; } } ``` ### Store Message IDs Always store message IDs for tracking: ```typescript async function sendOrderConfirmation(order: Order) { try { const response = await client.messages.send({ to: [order.customer.phone], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'order-confirmation', parameters: { order_id: order.id } } }); // Store for webhook correlation await db.messages.create({ sentMessageId: response.data.recipients[0].message_id, orderId: order.id, status: response.data.status, sentAt: new Date() }); return response; } catch (error) { // Handle error await db.messages.create({ orderId: order.id, status: 'FAILED', error: error.message, sentAt: new Date() }); throw error; } } ``` ```python def send_order_confirmation(order): try: response = client.messages.send( to=[order.customer.phone], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'order-confirmation', 'parameters': {'order_id': order.id} } ) # Store for webhook correlation db.messages.create( sent_message_id=response.data.recipients[0].message_id, order_id=order.id, status=response.data.status, sent_at=datetime.now() ) return response except Exception as e: # Handle error db.messages.create( order_id=order.id, status='FAILED', error=str(e), sent_at=datetime.now() ) raise ``` ## Environment Management ### Separate Credentials by Environment ```typescript // config/sent.ts const configs = { development: { apiKey: process.env.SENT_DM_API_KEY_TEST }, staging: { apiKey: process.env.SENT_DM_API_KEY_TEST }, production: { apiKey: process.env.SENT_DM_API_KEY } }; const env = (process.env.NODE_ENV as keyof typeof configs) || 'development'; export const sentClient = new SentDm(configs[env]); ``` ### Validate Configuration on Startup ```typescript function validateSentConfig() { if (!process.env.SENT_DM_API_KEY) { throw new Error('SENT_DM_API_KEY is required'); } if (!process.env.SENT_DM_WEBHOOK_SECRET) { console.warn('SENT_DM_WEBHOOK_SECRET not set - webhooks will fail'); } } // Run on application startup validateSentConfig(); ``` ## Summary --- Following these practices ensures your Sent integration is reliable, secure, and maintainable at scale. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/csharp.txt TITLE: C# / .NET SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/csharp.txt Official .NET SDK for Sent. Native async/await support for ASP.NET Core and .NET applications. # C# / .NET SDK The official .NET SDK for Sent provides first-class C# support with native async/await, dependency injection integration, and full compatibility with .NET Standard 2.0+. ## Requirements This library requires .NET Standard 2.0 or later. ## Installation ```bash dotnet add package Sentdm ``` ```powershell Install-Package Sentdm ``` ```xml ``` ## Quick Start ### Initialize the client ```csharp using Sentdm; // Configured using the SENT_DM_API_KEY environment variable SentClient client = new(); ``` ### Send your first message ```csharp using Sentdm; using Sentdm.Models.Messages; using System.Collections.Generic; SentClient client = new(); MessageSendParams parameters = new() { To = new List { "+1234567890" }, Channel = new List { "sms", "whatsapp", "rcs" }, Template = new Sentdm.Models.Messages.Template { ID = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome", Parameters = new Dictionary() { { "name", "John Doe" }, { "order_id", "12345" } } } }; var response = await client.Messages.Send(parameters); Console.WriteLine($"Sent: {response.Data.Recipients[0].MessageID}"); Console.WriteLine($"Status: {response.Data.Status}"); ``` ## Client configuration Configure the client using environment variables or explicitly: | Property | Environment variable | Required | Default value | |----------|---------------------|----------|---------------| | `ApiKey` | `SENT_DM_API_KEY` | true | - | | `BaseUrl` | `SENT_BASE_URL` | false | `"https://api.sent.dm"` | ```csharp using Sentdm; // Using environment variables SentClient client = new(); // Or explicit configuration SentClient client = new() { ApiKey = "your_api_key", }; // Or a combination SentClient client = new() { ApiKey = "your_api_key", // Explicit // Other settings from environment }; ``` ### Modifying configuration To temporarily use a modified client configuration, while reusing the same connection and thread pools, call `WithOptions`: ```csharp var clientWithOptions = client.WithOptions(options => options with { BaseUrl = "https://example.com", MaxRetries = 5, } ); ``` Using a [`with` expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/with-expression) makes it easy to construct the modified options. ## Send Messages ### Send a message ```csharp using Sentdm.Models.Messages; MessageSendParams parameters = new() { To = new List { "+1234567890" }, Channel = new List { "sms", "whatsapp", "rcs" }, Template = new Sentdm.Models.Messages.Template { ID = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome", Parameters = new Dictionary() { { "name", "John Doe" }, { "order_id", "12345" } } } }; var response = await client.Messages.Send(parameters); Console.WriteLine($"Message ID: {response.Data.Recipients[0].MessageID}"); Console.WriteLine($"Status: {response.Data.Status}"); ``` ### Sandbox mode Use `Sandbox = true` to validate requests without sending real messages: ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new Sentdm.Models.Messages.Template { ID = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome" }, Sandbox = true // Validates but doesn't send }; var response = await client.Messages.Send(parameters); // Response will have test data Console.WriteLine($"Validation passed: {response.Data.Recipients[0].MessageID}"); ``` ## Check message status Retrieve the current status of a sent message. The `Direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```csharp using Sentdm.Models.Messages; var status = await client.Messages.RetrieveStatus("msg-uuid"); Console.WriteLine($"Status: {status.Data.Status}"); // e.g. "DELIVERED" Console.WriteLine($"Channel: {status.Data.Channel}"); // e.g. "sms" Console.WriteLine($"Direction: {status.Data.Direction}"); // "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```csharp var activities = await client.Messages.RetrieveActivities("msg-uuid"); foreach (var activity in activities.Data.Activities) { Console.WriteLine($"{activity.Timestamp}: {activity.Status} via {activity.From}"); Console.WriteLine($" Price: {activity.Price}"); Console.WriteLine($" Active contact price: {activity.ActiveContactPrice}"); } ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```csharp var result = await client.Numbers.Lookup("+12025551234"); Console.WriteLine($"Valid: {result.Data.IsValid}"); Console.WriteLine($"Carrier: {result.Data.CarrierName}"); Console.WriteLine($"Line type: {result.Data.LineType}"); // "mobile", "landline", "voip" Console.WriteLine($"VoIP: {result.Data.IsVoip}"); ``` ## Error handling The SDK throws custom unchecked exception types. `SentApiException` is the base class for all API errors; the following subclasses are thrown per HTTP status code: | Status | Exception | |--------|-----------| | 400 | `SentBadRequestException` | | 401 | `SentUnauthorizedException` | | 403 | `SentForbiddenException` | | 404 | `SentNotFoundException` | | 422 | `SentUnprocessableEntityException` | | 429 | `SentRateLimitException` | | 5xx | `Sent5xxException` | | others | `SentUnexpectedStatusCodeException` | All 4xx exceptions also inherit from `Sent4xxException`. Catch `SentApiException` to handle any API error, as the last catch block below does: ```csharp try { var response = await client.Messages.Send(parameters); Console.WriteLine($"Sent: {response.Data.Recipients[0].MessageID}"); } catch (SentNotFoundException e) { Console.WriteLine($"Not found: {e.Message}"); } catch (SentRateLimitException e) { Console.WriteLine($"Rate limited. Retry after delay"); } catch (SentApiException e) { Console.WriteLine($"API Error: {e.Message}"); } ``` ## Raw responses To access response headers, status code, or raw body, prefix any HTTP method call with `WithRawResponse`: ```csharp var response = await client.WithRawResponse.Messages.Send(parameters); var statusCode = response.StatusCode; var headers = response.Headers; // Deserialize if needed var deserialized = await response.Deserialize(); ``` ## Retries The SDK automatically retries 2 times by default, with a short exponential backoff between requests. Only the following error types are retried: - Connection errors - 408 Request Timeout - 409 Conflict - 429 Rate Limit - 5xx Internal ```csharp using Sentdm; // Configure for all requests SentClient client = new() { MaxRetries = 3 }; // Or per-request await client .WithOptions(options => options with { MaxRetries = 3 }) .Messages.Send(parameters); ``` ## Timeouts Requests time out after 1 minute by default. ```csharp using System; using Sentdm; // Configure for all requests SentClient client = new() { Timeout = TimeSpan.FromSeconds(30) }; // Or per-request await client .WithOptions(options => options with { Timeout = TimeSpan.FromSeconds(30) }) .Messages.Send(parameters); ``` ## Contacts Create and manage contacts: ```csharp using Sentdm.Models.Contacts; // Create a contact ContactCreateParams createParams = new() { PhoneNumber = "+1234567890" }; var contact = await client.Contacts.Create(createParams); Console.WriteLine($"Contact ID: {contact.Data.ID}"); // List contacts ContactListParams listParams = new() { Page = 1, PageSize = 100 }; var contacts = await client.Contacts.List(listParams); foreach (var c in contacts.Data.Contacts) { Console.WriteLine($"{c.PhoneNumber} - {c.AvailableChannels}"); } // Get a contact var retrieved = await client.Contacts.Retrieve("contact-uuid"); // Update a contact ContactUpdateParams updateParams = new() { PhoneNumber = "+1987654321" }; var updated = await client.Contacts.Update("contact-uuid", updateParams); // Delete a contact await client.Contacts.Delete("contact-uuid"); ``` ## Templates List and retrieve templates: ```csharp using Sentdm.Models.Templates; // List templates var templates = await client.Templates.List(); foreach (var template in templates.Data.Templates) { Console.WriteLine($"{template.Name} ({template.Status}): {template.ID}"); } // Get a specific template var template = await client.Templates.Retrieve("template-uuid"); Console.WriteLine($"Name: {template.Data.Name}"); Console.WriteLine($"Status: {template.Data.Status}"); ``` ## Framework Integration A dedicated guide covers client registration, message sending, validation, and testing: ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```csharp using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("webhooks")] public class WebhookController : ControllerBase { [HttpPost("sent")] public async Task HandleWebhook() { // 1. Read raw body — do NOT use [FromBody] here using var ms = new MemoryStream(); await Request.Body.CopyToAsync(ms); var payload = ms.ToArray(); var webhookId = Request.Headers["X-Webhook-ID"].ToString(); var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString(); var signature = Request.Headers["X-Webhook-Signature"].ToString(); // 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" var secret = Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET")!; // "whsec_..." var keyBase64 = secret.StartsWith("whsec_") ? secret[6..] : secret; var keyBytes = Convert.FromBase64String(keyBase64); var signed = Encoding.UTF8.GetBytes($"{webhookId}.{timestamp}.{Encoding.UTF8.GetString(payload)}"); var expected = $"v1,{Convert.ToBase64String(HMACSHA256.HashData(keyBytes, signed))}"; if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expected))) { return Unauthorized(new { error = "Invalid signature" }); } // 3. Optional: reject replayed events older than 5 minutes if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return Unauthorized(new { error = "Timestamp too old" }); // 4. Handle events — update message status in your own database var doc = JsonDocument.Parse(payload); var field = doc.RootElement.GetProperty("field").GetString(); var eventType = doc.RootElement.TryGetProperty("event", out var et) ? et.GetString() : null; var data = doc.RootElement.GetProperty("payload"); if (field == "message") { if (eventType == "message.received") { // Inbound message from a contact var inboundNumber = data.GetProperty("inbound_number").GetString(); var outboundNumber = data.GetProperty("outbound_number").GetString(); var text = data.TryGetProperty("text", out var t) ? t.GetString() : null; var channel = data.GetProperty("channel").GetString(); var receivedAt = data.GetProperty("received_at").GetString(); // await db.InboundMessages.AddAsync(new InboundMessage { From = inboundNumber, To = outboundNumber, Text = text, ... }); } else { // Outbound message status update var messageId = data.GetProperty("message_id").GetString(); var status = data.GetProperty("message_status").GetString(); // await db.Messages.Where(m => m.SentId == Guid.Parse(messageId!)).ExecuteUpdateAsync(...) } } // 5. Always return 200 quickly return Ok(new { received = true }); } } ``` See the [Webhooks reference](/start/webhooks) for the full payload schema and all status values. ## Source & Issues - **Releases**: [GitHub Releases](https://github.com/sentdm/sent-dm-csharp/releases) - **GitHub**: [`sentdm/sent-dm-csharp`](https://github.com/sentdm/sent-dm-csharp) - **NuGet**: [Sentdm](https://www.nuget.org/packages/Sentdm) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-csharp/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/csharp/integrations/aspnet-core.txt TITLE: Sending messages from ASP.NET Core with the Sent C# SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/csharp/integrations/aspnet-core.txt Wire the Sent C# SDK into an ASP.NET Core app: install, register the client with DI, send from an endpoint, verify webhooks, and test with sandbox mode. # Sending messages from ASP.NET Core with the Sent C# SDK This guide shows you how to wire Sent messaging into an existing ASP.NET Core app: install the C# SDK, register the client with dependency injection, send a template message from an endpoint, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working ASP.NET Core 8 app using minimal APIs or controllers. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the package to your existing project: ```bash dotnet add package Sentdm ``` ### Register the client with dependency injection Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Register one shared client. `new SentClient()` reads `SENT_DM_API_KEY`, and `ISentClient` (shipped with the SDK) is the seam your services and tests depend on: ```csharp // Program.cs (excerpt) using Sentdm; var builder = WebApplication.CreateBuilder(args); var sentClient = new SentClient(); builder.Services.AddSingleton(typeof(ISentClient), sentClient); var app = builder.Build(); ``` ### Send a template message from an endpoint Define a request record; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```csharp // Messages/SendMessageBody.cs public record SendMessageBody( string PhoneNumber, // E.164 format, for example +14155551234 string TemplateName, // reference by Name or ID, never both Dictionary? Parameters, List? Channels, // omit to let Sent pick per recipient bool Sandbox = false); // true = validate and simulate only ``` Map the endpoint that calls `Messages.Send`: ```csharp // Program.cs (excerpt) using Sentdm.Models.Messages; app.MapPost("/api/messages/send", async (SendMessageBody body, ISentClient client, CancellationToken ct) => { var parameters = new MessageSendParams { To = new List { body.PhoneNumber }, Channel = body.Channels, Template = new Sentdm.Models.Messages.Template { Name = body.TemplateName, Parameters = body.Parameters ?? new Dictionary(), }, Sandbox = body.Sandbox, }; var result = await client.Messages.Send(parameters, ct); if (result.Success != true) { return Results.BadRequest(new { error = result.Error?.Message }); } var recipient = result.Data?.Recipients?.FirstOrDefault(); return Results.Accepted(value: new { message_id = recipient?.MessageID, status = result.Data?.Status, }); }); ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`. Delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a verification helper that checks the `X-Webhook-Signature` header against the raw request body before your handler trusts any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```csharp // Webhooks/WebhookSignature.cs using System.Security.Cryptography; using System.Text; public static class WebhookSignature { // Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" public static bool Verify(string rawBody, string webhookId, string timestamp, string signature, string? secret) { // Fail closed: never accept webhooks when the secret is not configured if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(signature)) { return false; } // Strip the "whsec_" prefix and base64-decode to get the raw HMAC key var keyBase64 = secret.StartsWith("whsec_") ? secret["whsec_".Length..] : secret; var keyBytes = Convert.FromBase64String(keyBase64); var signed = $"{webhookId}.{timestamp}.{rawBody}"; using var hmac = new HMACSHA256(keyBytes); var expected = "v1," + Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed))); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); } } ``` Map the webhook endpoint. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; return 200 quickly and do slow work elsewhere: ```csharp // Program.cs (excerpt) using System.Text.Json.Nodes; app.MapPost("/webhooks/sent", async (HttpRequest request) => { using var reader = new StreamReader(request.Body); var rawBody = await reader.ReadToEndAsync(); var webhookId = request.Headers["X-Webhook-ID"].ToString(); var timestamp = request.Headers["X-Webhook-Timestamp"].ToString(); var signature = request.Headers["X-Webhook-Signature"].ToString(); var secret = Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET"); if (!WebhookSignature.Verify(rawBody, webhookId, timestamp, signature, secret)) { return Results.Unauthorized(); } // Reject replayed events older than 5 minutes if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) { return Results.Unauthorized(); } var evt = JsonNode.Parse(rawBody); var payload = evt?["payload"]; if ((string?)evt?["field"] == "message") { var messageId = (string?)payload?["message_id"]; var status = (string?)payload?["message_status"]; switch ((string?)evt?["event"]) // sub-type; omitted for template events { case "message.delivered": app.Logger.LogInformation("Message {MessageId} delivered", messageId); break; case "message.failed": app.Logger.LogError("Message {MessageId} failed (status {Status})", messageId, status); break; case "message.received": app.Logger.LogInformation("Inbound {Channel} from {From}: {Text}", (string?)payload?["channel"], (string?)payload?["inbound_number"], (string?)payload?["text"]); break; default: app.Logger.LogInformation("Message {MessageId} status: {Status}", messageId, status); break; } } return Results.Ok(new { received = true }); }); ``` Keep this endpoint outside your authentication middleware, because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "ASP.NET Core integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash dotnet run ``` Send a sandbox message through your new endpoint (adjust the port to match your launch profile). Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:5000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155551234", "templateName": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your application log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If your project uses controllers instead of minimal APIs, move the endpoint bodies into actions. The SDK calls are identical; read the raw body with a `StreamReader` over `Request.Body` before model binding touches it. - If you bind settings through `IOptions`, put the API key in a `Sent` configuration section and validate it at startup. The appendix below shows the pattern. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and hand off to a background service or queue so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send free-form text instead of a template, set `Text` instead of `Template`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production ASP.NET Core stack. Adapt them to your own conventions rather than adopting them wholesale. Bind and validate configuration instead of reading environment variables directly; the host maps `Sent__ApiKey` onto `Sent:ApiKey` automatically: ```csharp // Configuration/SentOptions.cs public class SentOptions { public const string SectionName = "Sent"; [Required] public string ApiKey { get; set; } = string.Empty; public string? WebhookSecret { get; set; } public int TimeoutSeconds { get; set; } = 30; public int MaxRetries { get; set; } = 3; } ``` ```csharp // Program.cs (replace the direct registration) var sentOptions = new SentOptions(); builder.Configuration.GetSection(SentOptions.SectionName).Bind(sentOptions); if (string.IsNullOrEmpty(sentOptions.ApiKey)) { throw new InvalidOperationException("Sent:ApiKey is required"); } builder.Services.AddSingleton(sentOptions); ``` Reject malformed requests before they reach the SDK by annotating the request record: ```csharp // Messages/SendMessageBody.cs (attributes added) using System.ComponentModel.DataAnnotations; public record SendMessageBody( [property: Required, RegularExpression(@"^\+[1-9]\d{1,14}$", ErrorMessage = "Phone number must be in E.164 format")] string PhoneNumber, [property: Required, MaxLength(100)] string TemplateName, Dictionary? Parameters, List? Channels, bool Sandbox = false); ``` At the top of the endpoint, run `MiniValidator.TryValidate(body, out var errors)` (from the `MiniValidation` package) and return `Results.ValidationProblem(errors)` on failure. Cap how often callers can hit your send endpoint and standardize error responses: ```csharp // Program.cs (excerpt) builder.Services.AddProblemDetails(); builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter("sent-api", limiterOptions => { limiterOptions.PermitLimit = 100; limiterOptions.Window = TimeSpan.FromMinutes(1); }); }); app.UseRateLimiter(); // on the endpoint: .RequireRateLimiting("sent-api") ``` The [SDK testing guide](/sdks/testing) covers substituting `ISentClient` with a mock in endpoint tests. ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [C# SDK reference](/sdks/csharp) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/go.txt TITLE: Go SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/go.txt Official Go SDK for Sent. Lightweight, fast, and context-aware with minimal dependencies. # Go SDK The official Go SDK for Sent provides a lightweight, high-performance client with minimal dependencies. Built for microservices, CLI tools, and high-throughput applications with full context support. ## Requirements This library requires Go 1.22 or later. ## Installation ```bash go get github.com/sentdm/sent-dm-go ``` To pin a specific version, append a release tag from [GitHub Releases](https://github.com/sentdm/sent-dm-go/releases): ```bash go get github.com/sentdm/sent-dm-go@ ``` ## Quick Start ### Initialize the client ```go import ( "github.com/sentdm/sent-dm-go" ) // Reads the SENT_DM_API_KEY environment variable client := sentdm.NewClient() ``` ### Send your first message ```go package main import ( "context" "fmt" "log" "github.com/sentdm/sent-dm-go" ) func main() { // Reads the SENT_DM_API_KEY environment variable client := sentdm.NewClient() ctx := context.Background() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), Parameters: map[string]string{ "name": "John Doe", "order_id": "12345", }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Message sent: %s\n", response.Data.Recipients[0].MessageID) fmt.Printf("Status: %s\n", response.Data.Status) } ``` ## Authentication The client can be configured using environment variables or explicitly using functional options: ```go import ( "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) // Using environment variables (SENT_DM_API_KEY) client := sentdm.NewClient() // Or explicit configuration client := sentdm.NewClient( option.WithAPIKey("your_api_key"), ) ``` ## Request fields The `sentdm` library follows the [`omitzero`](https://tip.golang.org/doc/go1.24#encodingjsonpkgencodingjson) serialization semantics that the Go 1.24 `encoding/json` release introduced for request fields. This is a serialization convention implemented by the library itself; the minimum supported Go version remains 1.22. Required primitive fields feature the tag `json:"...,required"`. These fields are always serialized, even their zero values. Optional primitive types are wrapped in a `param.Opt[T]`. These fields can be set with the provided constructors, `sentdm.String()`, `sentdm.Int()`, etc. ```go params := sentdm.MessageSendParams{ To: []string{"+1234567890"}, // required property Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("template-id"), Name: sentdm.String("welcome"), }, Channel: []string{"rcs", "sms"}, // optional — "rcs", "sms", "whatsapp", or combinations } ``` To send `null` instead of a `param.Opt[T]`, use `param.Null[T]()`. To check if a field is omitted, use `param.IsOmitted()`. ## Send Messages ### Send a message ```go response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), Parameters: map[string]string{ "name": "John Doe", "order_id": "12345", }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Sent: %s\n", response.Data.Recipients[0].MessageID) fmt.Printf("Status: %s\n", response.Data.Status) ``` ### Sandbox mode Use `Sandbox` to validate requests without sending real messages: ```go response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, Sandbox: sentdm.Bool(true), // Validates but doesn't send }) if err != nil { log.Fatal(err) } // Response will have test data fmt.Printf("Validation passed: %s\n", response.Data.Recipients[0].MessageID) ``` ## Check message status Retrieve the current status of a sent message. The `Direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```go status, err := client.Messages.GetStatus(ctx, "msg-uuid", sentdm.MessageGetStatusParams{}) if err != nil { log.Fatal(err) } fmt.Printf("Status: %s\n", status.Data.Status) // e.g. "DELIVERED" fmt.Printf("Channel: %s\n", status.Data.Channel) // e.g. "sms" fmt.Printf("Direction: %s\n", status.Data.Direction) // "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```go activities, err := client.Messages.GetActivities(ctx, "msg-uuid", sentdm.MessageGetActivitiesParams{}) if err != nil { log.Fatal(err) } for _, activity := range activities.Data.Activities { fmt.Printf("%s: %s via %s\n", activity.Timestamp, activity.Status, activity.From) fmt.Printf(" Price: %s | Active contact price: %s\n", activity.Price, activity.ActiveContactPrice) } ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```go result, err := client.Numbers.Lookup(ctx, "+12025551234", sentdm.NumberLookupParams{}) if err != nil { log.Fatal(err) } fmt.Printf("Valid: %v\n", result.Data.IsValid) fmt.Printf("Carrier: %s\n", result.Data.CarrierName) fmt.Printf("Line type: %s\n", result.Data.LineType) // "mobile", "landline", "voip" fmt.Printf("VoIP: %v\n", result.Data.IsVoip) ``` ## Error handling When the API returns a non-success status code, the SDK returns an error of type `*sentdm.Error`: ```go response, err := client.Messages.Send(ctx, params) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { fmt.Printf("API error: %s\n", apiErr.Error()) fmt.Printf("Status: %d\n", apiErr.StatusCode) } else { fmt.Printf("Other error: %v\n", err) } } ``` ## Pagination Use `.List()` to fetch a page of results. Pass `Page` and `PageSize` to control pagination: ```go page, err := client.Contacts.List(ctx, sentdm.ContactListParams{ Page: 1, PageSize: 100, }) if err != nil { log.Fatal(err) } for _, contact := range page.Data.Contacts { fmt.Printf("%s\n", contact.PhoneNumber) } ``` ## Contacts Create and manage contacts: ```go // Create a contact response, err := client.Contacts.New(ctx, sentdm.ContactNewParams{ PhoneNumber: "+1234567890", }) if err != nil { log.Fatal(err) } fmt.Printf("Contact ID: %s\n", response.Data.ID) // List contacts page, err := client.Contacts.List(ctx, sentdm.ContactListParams{ Page: 1, PageSize: 100, }) // Get a contact contact, err := client.Contacts.Get(ctx, "contact-uuid", sentdm.ContactGetParams{}) // Update a contact response, err = client.Contacts.Update(ctx, "contact-uuid", sentdm.ContactUpdateParams{ DefaultChannel: sentdm.String("whatsapp"), }) // Delete a contact err = client.Contacts.Delete(ctx, "contact-uuid", sentdm.ContactDeleteParams{}) ``` ## Templates List and retrieve templates: ```go // List templates templates, err := client.Templates.List(ctx, sentdm.TemplateListParams{}) if err != nil { log.Fatal(err) } for _, template := range templates.Data.Templates { fmt.Printf("%s (%s): %s\n", template.Name, template.Status, template.ID) } // Get a template template, err := client.Templates.Get(ctx, "template-uuid", sentdm.TemplateGetParams{}) fmt.Printf("Name: %s\n", template.Data.Name) fmt.Printf("Status: %s\n", template.Data.Status) ``` ## RequestOptions This library uses the functional options pattern. Functions defined in the `option` package return a `RequestOption`, which is a closure that mutates a `RequestConfig`. These options can be supplied to the client or at individual requests: ```go client := sentdm.NewClient( // Adds a header to every request made by the client option.WithHeader("X-Some-Header", "custom_header_info"), ) // Override per-request response, err := client.Messages.Send(ctx, params, option.WithHeader("X-Some-Header", "some_other_value"), option.WithJSONSet("custom.field", map[string]string{"my": "object"}), ) ``` The request option `option.WithDebugLog(nil)` may be helpful while debugging. See the [full list of request options](https://pkg.go.dev/github.com/sentdm/sent-dm-go/option). ## Framework Integration Dedicated guides cover client setup, message sending, verified webhook handling, and sandbox testing for each framework: ## Response objects All fields in response structs are ordinary value types. Response structs also include a special `JSON` field containing metadata about each property. ```go response, err := client.Templates.Get(ctx, "template-uuid", sentdm.TemplateGetParams{}) if err != nil { log.Fatal(err) } fmt.Println(response.Data.Name) // Access the field directly // Check if field was present in response if response.Data.JSON.Name.Valid() { fmt.Println("Name was present") } // Access raw JSON fmt.Println(response.Data.JSON.Name.Raw()) ``` ## Concurrent Sending Use Go's concurrency for high-throughput: ```go func sendBulkMessages( client *sentdm.Client, phoneNumbers []string, templateID string, ) error { var wg sync.WaitGroup errChan := make(chan error, len(phoneNumbers)) // Semaphore to limit concurrency sem := make(chan struct{}, 10) for _, phone := range phoneNumbers { wg.Add(1) go func(p string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{p}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String(templateID), }, }) if err != nil { errChan <- fmt.Errorf("failed to send to %s: %w", p, err) } }(phone) } wg.Wait() close(errChan) var errs []error for err := range errChan { errs = append(errs, err) } if len(errs) > 0 { return fmt.Errorf("failed to send %d messages", len(errs)) } return nil } ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "math" "net/http" "os" "strconv" "strings" "time" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } webhookID := r.Header.Get("X-Webhook-ID") timestamp := r.Header.Get("X-Webhook-Timestamp") signature := r.Header.Get("X-Webhook-Signature") // 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" raw := os.Getenv("SENT_DM_WEBHOOK_SECRET") // "whsec_abc123..." keyStr := strings.TrimPrefix(raw, "whsec_") keyBytes, _ := base64.StdEncoding.DecodeString(keyStr) signed := webhookID + "." + timestamp + "." + string(body) mac := hmac.New(sha256.New, keyBytes) mac.Write([]byte(signed)) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(signature), []byte(expected)) { http.Error(w, `{"error":"invalid signature"}`, http.StatusUnauthorized) return } // 2. Optional: reject replayed events older than 5 minutes ts, _ := strconv.ParseInt(timestamp, 10, 64) if math.Abs(float64(time.Now().Unix()-ts)) > 300 { http.Error(w, `{"error":"timestamp too old"}`, http.StatusUnauthorized) return } var event struct { Field string `json:"field"` Event string `json:"event"` Payload map[string]any `json:"payload"` } json.Unmarshal(body, &event) // 3. Handle events — update message status in your own database if event.Field == "message" { if event.Event == "message.received" { // Inbound message from a contact inboundNumber := event.Payload["inbound_number"] text := event.Payload["text"] channel := event.Payload["channel"] // db.CreateInboundMessage(ctx, inboundNumber.(string), text.(string), channel.(string)) _ = inboundNumber; _ = text; _ = channel } else { // Outbound message status update messageID := event.Payload["message_id"] status := event.Payload["message_status"] // db.UpdateMessageStatus(ctx, messageID.(string), status.(string)) _ = messageID; _ = status } } // 4. Always return 200 quickly w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"received":true}`)) } ``` See the [Webhooks reference](/start/webhooks) for the full payload schema and all status values. ## Source & Issues - **Releases**: [GitHub Releases](https://github.com/sentdm/sent-dm-go/releases) - **GitHub**: [`sentdm/sent-dm-go`](https://github.com/sentdm/sent-dm-go) - **GoDoc**: [`pkg.go.dev/github.com/sentdm/sent-dm-go`](https://pkg.go.dev/github.com/sentdm/sent-dm-go) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-go/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/go/integrations/echo.txt TITLE: Sending messages from an Echo service with the Sent Go SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/go/integrations/echo.txt Wire the Sent Go SDK into an Echo service: install, configure the client, send a template message from a handler, verify webhooks, and test with sandbox mode. # Sending messages from an Echo service with the Sent Go SDK This guide shows you how to wire Sent messaging into an existing Echo service: install the Go SDK, configure a shared client, send a template message from a handler, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Echo v4 service on Go 1.22+ and familiarity with handlers and groups. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing module: ```bash go get github.com/sentdm/sent-dm-go ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Create one shared client at startup and pass it to your handlers: ```go // main.go (excerpt) client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")), ) ``` The imports are `github.com/sentdm/sent-dm-go` and `github.com/sentdm/sent-dm-go/option`. The client is safe for concurrent use; one instance serves the whole process. ### Send a template message from a handler Add a handler that binds the request and calls `Messages.Send`; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```go // internal/handler/message.go package handler import ( "context" "net/http" "time" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go" ) type SendMessageRequest struct { To []string `json:"to"` // E.164 numbers TemplateName string `json:"template_name"` // or template_id, never both Parameters map[string]string `json:"parameters,omitempty"` Channels []string `json:"channels,omitempty"` Sandbox bool `json:"sandbox"` // true = validate and simulate only } type MessageHandler struct { client *sentdm.Client } func NewMessageHandler(client *sentdm.Client) *MessageHandler { return &MessageHandler{client: client} } func (h *MessageHandler) Register(e *echo.Echo) { e.POST("/api/messages/send", h.SendMessage) } func (h *MessageHandler) SendMessage(c echo.Context) error { var req SendMessageRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if len(req.To) == 0 || req.TemplateName == "" { return echo.NewHTTPError(http.StatusBadRequest, "to and template_name are required") } ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second) defer cancel() response, err := h.client.Messages.Send(ctx, sentdm.MessageSendParams{ To: req.To, Template: sentdm.MessageSendParamsTemplate{ Name: sentdm.String(req.TemplateName), Parameters: req.Parameters, }, Channel: req.Channels, // omit to let Sent pick per recipient Sandbox: sentdm.Bool(req.Sandbox), }) if err != nil { return echo.NewHTTPError(http.StatusBadGateway, err.Error()) } recipient := response.Data.Recipients[0] return c.JSON(http.StatusAccepted, map[string]string{ "message_id": recipient.MessageID, "status": response.Data.Status, }) } ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`: delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a webhook handler that verifies the `X-Webhook-Signature` header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them: ```go // internal/handler/webhook.go package handler import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "os" "strings" "time" "github.com/labstack/echo/v4" ) // WebhookEvent is the envelope Sent posts to your endpoint. Event is set for // message events (for example, "message.delivered") and omitted for template events. type WebhookEvent struct { Field string `json:"field"` Event string `json:"event,omitempty"` Timestamp time.Time `json:"timestamp"` Payload WebhookPayload `json:"payload"` } type WebhookPayload struct { MessageID string `json:"message_id,omitempty"` MessageStatus string `json:"message_status,omitempty"` Channel string `json:"channel,omitempty"` InboundNumber string `json:"inbound_number,omitempty"` Text string `json:"text,omitempty"` } type WebhookHandler struct { secret string logger *slog.Logger } func NewWebhookHandler(logger *slog.Logger) *WebhookHandler { return &WebhookHandler{secret: os.Getenv("SENT_DM_WEBHOOK_SECRET"), logger: logger} } func (h *WebhookHandler) Register(e *echo.Echo) { e.POST("/webhooks/sent", h.HandleWebhook) } // verifySignature: strip the "whsec_" prefix, base64-decode the remainder to get // the raw key, sign "{webhookID}.{timestamp}.{rawBody}" with HMAC-SHA256, and // compare against the "v1,{base64(hmac)}" header using a constant-time check. func (h *WebhookHandler) verifySignature(webhookID, timestamp string, body []byte, signature string) bool { if h.secret == "" { return false // fail closed when the secret is not configured } keyBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(h.secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, keyBytes) mac.Write([]byte(webhookID + "." + timestamp + "." + string(body))) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) } func (h *WebhookHandler) HandleWebhook(c echo.Context) error { body, err := io.ReadAll(c.Request().Body) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "failed to read body") } webhookID := c.Request().Header.Get("X-Webhook-ID") timestamp := c.Request().Header.Get("X-Webhook-Timestamp") signature := c.Request().Header.Get("X-Webhook-Signature") if !h.verifySignature(webhookID, timestamp, body, signature) { return echo.NewHTTPError(http.StatusUnauthorized, "invalid signature") } var event WebhookEvent if err := json.Unmarshal(body, &event); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid JSON") } if event.Field == "message" { switch event.Event { case "message.delivered": h.logger.Info("message delivered", slog.String("message_id", event.Payload.MessageID)) case "message.failed": h.logger.Error("message failed", slog.String("message_id", event.Payload.MessageID), slog.String("status", event.Payload.MessageStatus)) case "message.received": h.logger.Info("inbound message", slog.String("from", event.Payload.InboundNumber), slog.String("text", event.Payload.Text)) default: h.logger.Info("message status updated", slog.String("message_id", event.Payload.MessageID), slog.String("status", event.Payload.MessageStatus)) } } return c.JSON(http.StatusOK, map[string]bool{"received": true}) } ``` Register the handler outside any authentication middleware, because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Echo integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret` (put it in `SENT_DM_WEBHOOK_SECRET`). The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the service with your credentials loaded: ```bash go run ./cmd/api ``` Send a sandbox message through your new handler. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:8080/api/messages/send \ -H "Content-Type: application/json" \ -d '{"to": ["+14155551234"], "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your service log should show a `message delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you use Echo's `Validator` interface with `go-playground/validator`, move the required-field checks into struct tags and call `c.Validate(&req)` after binding. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and process in a goroutine or job queue so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send to many recipients, pass them all in `To`. Sent creates one message per recipient-and-channel pair in a single call. - To send free-form text instead of a template, set `Text` instead of `Template`. Each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Echo service. Adapt them to your own conventions rather than adopting them wholesale. Put the SDK behind a small interface so handlers can be tested with a mock: ```go // internal/service/message.go type MessageSender interface { SendMessage(ctx context.Context, req *SendMessageRequest) (*SendMessageResult, error) } ``` Assert against the mock in handler tests; the [SDK testing guide](/sdks/testing) covers sandbox-based integration tests. Wire zap or slog through Echo's middleware chain, with recovery and request IDs: ```go // main.go (excerpt) e := echo.New() e.Use(middleware.Recover()) e.Use(middleware.RequestID()) e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ LogStatus: true, LogURI: true, LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { logger.Info("request", slog.String("uri", v.URI), slog.Int("status", v.Status)) return nil }, })) ``` Drain in-flight requests on shutdown and ship a minimal image: ```go // main.go (excerpt) go func() { if err := e.Start(":8080"); err != nil && !errors.Is(err, http.ErrServerClosed) { e.Logger.Fatal(err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() _ = e.Shutdown(ctx) ``` ```dockerfile FROM golang:1.23-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /app/main . EXPOSE 8080 CMD ["./main"] ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Go SDK reference](/sdks/go) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/go/integrations/gin.txt TITLE: Sending messages from a Gin service with the Sent Go SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/go/integrations/gin.txt Wire the Sent Go SDK into a Gin service: install, configure the client, send a template message from a handler, verify webhooks, and test with sandbox mode. # Sending messages from a Gin service with the Sent Go SDK This guide shows you how to wire Sent messaging into an existing Gin service: install the Go SDK, configure a shared client, send a template message from a handler, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Gin service on Go 1.22+ and familiarity with handlers and middleware. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing module: ```bash go get github.com/sentdm/sent-dm-go ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Create one shared client at startup and pass it to your handlers: ```go // main.go (excerpt) client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")), ) ``` The imports are `github.com/sentdm/sent-dm-go` and `github.com/sentdm/sent-dm-go/option`. The client is safe for concurrent use; one instance serves the whole process. ### Send a template message from a handler Add a handler that binds the request and calls `Messages.Send`; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```go // internal/handlers/message_handler.go package handlers import ( "context" "net/http" "time" "github.com/gin-gonic/gin" "github.com/sentdm/sent-dm-go" ) type SendMessageRequest struct { To []string `json:"to" binding:"required,min=1"` // E.164 numbers TemplateName string `json:"template_name" binding:"required"` // or template_id, never both Parameters map[string]string `json:"parameters,omitempty"` Channels []string `json:"channels,omitempty" binding:"dive,oneof=sms whatsapp rcs"` Sandbox bool `json:"sandbox"` // true = validate and simulate only } type MessageHandler struct { client *sentdm.Client } func NewMessageHandler(client *sentdm.Client) *MessageHandler { return &MessageHandler{client: client} } func (h *MessageHandler) SendMessage(c *gin.Context) { var req SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() response, err := h.client.Messages.Send(ctx, sentdm.MessageSendParams{ To: req.To, Template: sentdm.MessageSendParamsTemplate{ Name: sentdm.String(req.TemplateName), Parameters: req.Parameters, }, Channel: req.Channels, // omit to let Sent pick per recipient Sandbox: sentdm.Bool(req.Sandbox), }) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } recipient := response.Data.Recipients[0] c.JSON(http.StatusAccepted, gin.H{ "message_id": recipient.MessageID, "status": response.Data.Status, }) } ``` Register the route on your engine: ```go router.POST("/api/messages/send", messageHandler.SendMessage) ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`: delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a webhook handler that verifies the `X-Webhook-Signature` header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them: ```go // internal/handlers/webhook_handler.go package handlers import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "os" "strings" "time" "github.com/gin-gonic/gin" ) // WebhookEvent is the envelope Sent posts to your endpoint. Event is set for // message events (for example, "message.delivered") and omitted for template events. type WebhookEvent struct { Field string `json:"field"` Event string `json:"event,omitempty"` Timestamp time.Time `json:"timestamp"` Payload WebhookPayload `json:"payload"` } type WebhookPayload struct { MessageID string `json:"message_id,omitempty"` MessageStatus string `json:"message_status,omitempty"` Channel string `json:"channel,omitempty"` InboundNumber string `json:"inbound_number,omitempty"` Text string `json:"text,omitempty"` } type WebhookHandler struct { secret string logger *slog.Logger } func NewWebhookHandler(logger *slog.Logger) *WebhookHandler { return &WebhookHandler{secret: os.Getenv("SENT_DM_WEBHOOK_SECRET"), logger: logger} } // verifySignature: strip the "whsec_" prefix, base64-decode the remainder to get // the raw key, sign "{webhookID}.{timestamp}.{rawBody}" with HMAC-SHA256, and // compare against the "v1,{base64(hmac)}" header using a constant-time check. func (h *WebhookHandler) verifySignature(webhookID, timestamp string, body []byte, signature string) bool { if h.secret == "" { return false // fail closed when the secret is not configured } keyBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(h.secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, keyBytes) mac.Write([]byte(webhookID + "." + timestamp + "." + string(body))) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) } func (h *WebhookHandler) HandleWebhook(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"}) return } webhookID := c.GetHeader("X-Webhook-ID") timestamp := c.GetHeader("X-Webhook-Timestamp") signature := c.GetHeader("X-Webhook-Signature") if !h.verifySignature(webhookID, timestamp, body, signature) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) return } var event WebhookEvent if err := json.Unmarshal(body, &event); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) return } if event.Field == "message" { switch event.Event { case "message.delivered": h.logger.Info("message delivered", slog.String("message_id", event.Payload.MessageID)) case "message.failed": h.logger.Error("message failed", slog.String("message_id", event.Payload.MessageID), slog.String("status", event.Payload.MessageStatus)) case "message.received": h.logger.Info("inbound message", slog.String("from", event.Payload.InboundNumber), slog.String("text", event.Payload.Text)) default: h.logger.Info("message status updated", slog.String("message_id", event.Payload.MessageID), slog.String("status", event.Payload.MessageStatus)) } } c.JSON(http.StatusOK, gin.H{"received": true}) } ``` Register the route outside any authentication middleware, because Sent authenticates with the signature: ```go router.POST("/webhooks/sent", webhookHandler.HandleWebhook) ``` Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Gin integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret` (put it in `SENT_DM_WEBHOOK_SECRET`). The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the service with your credentials loaded: ```bash go run ./cmd/api ``` Send a sandbox message through your new handler. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:8080/api/messages/send \ -H "Content-Type: application/json" \ -d '{"to": ["+14155551234"], "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your service log should show a `message delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If your service wraps dependencies in a service layer, move the `Messages.Send` call behind an interface so tests can mock it. The appendix below shows the seam. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and process in a goroutine or job queue so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send to many recipients, pass them all in `To`. Sent creates one message per recipient-and-channel pair in a single call. - To send free-form text instead of a template, set `Text` instead of `Template`. Each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Gin service. Adapt them to your own conventions rather than adopting them wholesale. Load typed configuration and bind the standard Sent variable names to nested keys: ```go // internal/config/config.go viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() _ = viper.BindEnv("sent.api_key", "SENT_DM_API_KEY") _ = viper.BindEnv("sent.webhook_secret", "SENT_DM_WEBHOOK_SECRET") ``` Put the SDK behind a small interface so handlers can be tested with a mock: ```go // internal/service/message_service.go type MessageSender interface { SendMessage(ctx context.Context, req *SendMessageRequest) (*SendMessageResult, error) } ``` Assert against the mock in handler tests; the [SDK testing guide](/sdks/testing) covers sandbox-based integration tests. Cap per-IP request rates with `golang.org/x/time/rate`, log with request IDs, and drain in-flight requests on shutdown: ```go // main.go (excerpt) srv := &http.Server{Addr: ":8080", Handler: router, ReadTimeout: 30 * time.Second} go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("server failed", slog.String("error", err.Error())) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() _ = srv.Shutdown(ctx) ``` Build a static binary and ship a minimal image; credentials come in as environment variables at run time: ```dockerfile FROM golang:1.23-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /app/main . EXPOSE 8080 CMD ["./main"] ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Go SDK reference](/sdks/go) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks.txt TITLE: Sent SDKs ================================================================================ URL: https://docs.sent.dm/llms/sdks.txt # Sent SDKs Official SDKs for Sent's unified messaging API. Send SMS, WhatsApp, and RCS messages using your favorite programming language. Code2, Braces, Coffee, Hash, Terminal, Globe, Gem, Layers, Server, Flame, Box, Leaf, Zap, Shield, Clock, Diamond, Code } from "lucide-react"; # Sent SDKs Build messaging into your app in minutes. The official Sent SDKs provide idiomatic, type-safe clients for every major language, with automatic retries, webhook signature verification, and intelligent error handling built in. **New to Sent?** Start with the [Quickstart Guide](/start/quickstart) to set up your account and send your first message. ## Prerequisites Before using any SDK, you'll need: 1. **A Sent account** - Sign up at [app.sent.dm](https://app.sent.dm) 2. **An API key** - Get yours from the [Sent Dashboard](https://app.sent.dm/dashboard/api-keys) 3. **A template** - Create a message template in the dashboard (WhatsApp templates require approval) **Environment Variable:** All SDKs use `SENT_DM_API_KEY` for authentication. Set this in your environment before running your application. ## Why Use the SDKs? } description="Full type definitions for TypeScript, Python, Go, Java, C#, PHP, and Ruby. Catch errors at compile time." /> } description="Automatic exponential backoff for rate limits and transient failures. Configurable retry policies." /> } description="Built-in signature verification to ensure webhook events are authentic and untampered." /> } description="Most SDKs have zero external dependencies. Just install and start sending messages." /> ## Official SDKs } /> } /> } /> } /> } /> } /> } /> ## Quick Comparison See how simple it is to send a message in each language: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // Uses SENT_DM_API_KEY env var const response = await client.messages.send({ to: ['+1234567890'], template: { id: 'your-template-id', name: 'welcome' }, // sandbox: true, // Uncomment to test without sending }); console.log(`Sent: ${response.data.recipients[0].message_id}`); ``` ```python from sent_dm import Sent client = Sent() # Uses SENT_DM_API_KEY env var response = client.messages.send( to=["+1234567890"], template={ "id": "your-template-id", "name": "welcome" }, # sandbox=True, # Uncomment to test without sending ) print(f"Sent: {response.data.recipients[0].message_id}") ``` ```go import ( "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) client := sentdm.NewClient() // Uses SENT_DM_API_KEY env var response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("your-template-id"), Name: sentdm.String("welcome"), }, // Sandbox: sentdm.Bool(true), // Uncomment to test without sending }) ``` ```java import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; SentClient client = SentOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("your-template-id") .build()) // .sandbox(true) // Uncomment to test without sending .build(); MessageSendResponse response = client.messages().send(params); System.out.println("Sent: " + response.data().recipients().get().get(0).messageId()); ``` ```csharp using Sentdm; using Sentdm.Models.Messages; SentClient client = new(); // Uses SENT_DM_API_KEY env var var response = await client.Messages.Send(new MessageSendParams { To = new List { "+1234567890" }, Template = new Template { ID = "your-template-id" }, // Sandbox = true, // Uncomment to test without sending }); Console.WriteLine($"Sent: {response.Data.Recipients[0].MessageID}"); ``` ```php use SentDm\Client; $client = new Client($_ENV['SENT_DM_API_KEY']); $response = $client->messages->send( to: ['+1234567890'], template: [ 'id' => 'your-template-id', 'name' => 'welcome' ], // sandbox: true, // Uncomment to test without sending ); echo "Sent: {$response->data->recipients[0]->messageID}\n"; ``` ```ruby require "sentdm" sent_dm = Sentdm::Client.new # Uses SENT_DM_API_KEY env var response = sent_dm.messages.send_( to: ['+1234567890'], template: { id: 'your-template-id', name: 'welcome' }, # sandbox: true, # Uncomment to test without sending ) puts "Sent: #{response.data.recipients[0].message_id}" ``` **Note:** Each SDK follows its language's conventions. Method names, parameter styles, and error handling vary by language. See the individual SDK pages for detailed documentation. All SDKs support RCS via `channel: ["rcs"]` or `channel: ["rcs", "sms"]` (RCS with SMS fallback). No additional setup is needed in your code once your RCS Agent is approved. **Testing:** Use `sandbox: true` (or `sandbox=True` in Python) in development to validate requests without sending real messages. The API will validate your request but not actually send any messages. ## SDK Versions Each SDK is versioned independently. Find the current version and changelog for each SDK on its GitHub Releases page: | Language | Package | Releases & changelog | |----------|---------|----------------------| | **TypeScript** | `@sentdm/sentdm` | [GitHub Releases](https://github.com/sentdm/sent-dm-typescript/releases) | | **Python** | `sentdm` | [GitHub Releases](https://github.com/sentdm/sent-dm-python/releases) | | **Go** | `github.com/sentdm/sent-dm-go` | [GitHub Releases](https://github.com/sentdm/sent-dm-go/releases) | | **Java** | `dm.sent:sent-java` | [GitHub Releases](https://github.com/sentdm/sent-dm-java/releases) | | **C#** | `Sentdm` | [GitHub Releases](https://github.com/sentdm/sent-dm-csharp/releases) | | **PHP** | `sentdm/sent-dm-php` | [GitHub Releases](https://github.com/sentdm/sent-dm-php/releases) | | **Ruby** | `sentdm` | [GitHub Releases](https://github.com/sentdm/sent-dm-ruby/releases) | ## Framework Quickstarts Get up and running quickly with popular frameworks: } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ## Installation All SDKs are available through standard package managers: ```bash npm install @sentdm/sentdm ``` ```bash pip install sentdm ``` ```bash go get github.com/sentdm/sent-dm-go ``` ```xml dm.sent sent-java x.y.z ``` ```bash dotnet add package Sentdm ``` ```bash composer require sentdm/sent-dm-php ``` ```bash gem install sentdm ``` ## Open Source All Sent SDKs are open source and available on GitHub: - [TypeScript SDK](https://github.com/sentdm/sent-dm-typescript) - [Python SDK](https://github.com/sentdm/sent-dm-python) - [Go SDK](https://github.com/sentdm/sent-dm-go) - [Java SDK](https://github.com/sentdm/sent-dm-java) - [C# SDK](https://github.com/sentdm/sent-dm-csharp) - [PHP SDK](https://github.com/sentdm/sent-dm-php) - [Ruby SDK](https://github.com/sentdm/sent-dm-ruby) **Contributions welcome!** Found a bug or want to add a feature? We accept pull requests on all SDK repositories. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/java.txt TITLE: Java SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/java.txt Official Java SDK for Sent. Send messages from JVM services, with Spring Boot integration and full async support. # Java SDK The official Java SDK for Sent provides an enterprise-ready client with full support for synchronous and asynchronous operations. Built for Spring Boot, Jakarta EE, and standalone applications with builder patterns throughout. ## Requirements This library requires Java 8 or later. ## Installation ```xml dm.sent sent-java x.y.z ``` ```kotlin // Use the latest version from https://github.com/sentdm/sent-dm-java/releases implementation("dm.sent:sent-java:x.y.z") ``` ## Quick Start ### Initialize the client ```java import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; // Configures using the `sent.dmApiKey` system property // Or configures using the `SENT_DM_API_KEY` environment variable SentClient client = SentOkHttpClient.fromEnv(); ``` ### Send your first message ```java import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; SentClient client = SentOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addChannel("sms") .addChannel("whatsapp") .addChannel("rcs") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("order_confirmation") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("name", JsonValue.from("John Doe")) .putAdditionalProperty("order_id", JsonValue.from("12345")) .build()) .build()) .build(); MessageSendResponse response = client.messages().send(params); System.out.println("Sent: " + response.data().recipients().get().get(0).messageId()); System.out.println("Status: " + response.data().status()); ``` ## Client configuration Configure the client using system properties or environment variables: | Setter | System property | Environment variable | Required | Default value | |--------|-----------------|----------------------|----------|---------------| | `apiKey` | `sent.dmApiKey` | `SENT_DM_API_KEY` | true | - | | `baseUrl` | `sent.baseUrl` | `SENT_BASE_URL` | false | `"https://api.sent.dm"` | System properties take precedence over environment variables. ```java import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; // From environment variables SentClient client = SentOkHttpClient.fromEnv(); // Or manually configure SentClient client = SentOkHttpClient.builder() .apiKey("your_api_key") .build(); // Or combine both approaches SentClient client = SentOkHttpClient.builder() .fromEnv() .apiKey("overridden_api_key") .build(); ``` Don't create more than one client in the same application. Each client has a connection pool and thread pools, which are more efficient to share between requests. ## Send Messages ### Send a message ```java import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addChannel("sms") .addChannel("whatsapp") .addChannel("rcs") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("order_confirmation") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("name", JsonValue.from("John Doe")) .putAdditionalProperty("order_id", JsonValue.from("12345")) .build()) .build()) .build(); MessageSendResponse response = client.messages().send(params); System.out.println("Message ID: " + response.data().recipients().get().get(0).messageId()); System.out.println("Status: " + response.data().status()); ``` ### Sandbox mode Use `sandbox(true)` to validate requests without sending real messages: ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("order_confirmation") .build()) .sandbox(true) // Validates but doesn't send .build(); MessageSendResponse response = client.messages().send(params); // Response will have test data System.out.println("Validation passed: " + response.data().recipients().get().get(0).messageId()); ``` ## Check message status Retrieve the current status of a sent message. The `direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```java import dm.sent.models.messages.MessageRetrieveStatusResponse; MessageRetrieveStatusResponse status = client.messages().retrieveStatus("msg-uuid"); System.out.println("Status: " + status.data().status()); // e.g. "DELIVERED" System.out.println("Channel: " + status.data().channel()); // e.g. "sms" System.out.println("Direction: " + status.data().direction()); // "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```java import dm.sent.models.messages.MessageRetrieveActivitiesResponse; MessageRetrieveActivitiesResponse activities = client.messages().retrieveActivities("msg-uuid"); activities.data().activities().forEach(activity -> { System.out.println(activity.timestamp() + ": " + activity.status() + " via " + activity.from()); System.out.println(" Price: " + activity.price()); System.out.println(" Active contact price: " + activity.activeContactPrice()); }); ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```java import dm.sent.models.numbers.NumberLookupResponse; NumberLookupResponse result = client.numbers().lookup("+12025551234"); System.out.println("Carrier: " + result.data().carrierName()); System.out.println("Line type: " + result.data().lineType()); // "mobile", "landline", "voip" System.out.println("VoIP: " + result.data().isVoip()); ``` ## Asynchronous execution The default client is synchronous. To switch to asynchronous execution, call the `async()` method: ```java import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; import java.util.concurrent.CompletableFuture; SentClient client = SentOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .build()) .build(); CompletableFuture future = client.async().messages().send(params); // Handle result future.thenAccept(response -> { System.out.println("Sent: " + response.data().recipients().get().get(0).messageId()); }).exceptionally(throwable -> { System.err.println("Failed: " + throwable.getMessage()); return null; }); ``` Or create an asynchronous client from the beginning: ```java import dm.sent.client.SentClientAsync; import dm.sent.client.okhttp.SentOkHttpClientAsync; SentClientAsync client = SentOkHttpClientAsync.fromEnv(); CompletableFuture future = client.messages().send(params); ``` ## Error handling When the API returns a non-success status code, a subclass of `SentServiceException` (itself a subclass of `SentException`) will be thrown: | Status | Exception | |--------|-----------| | 400 | `BadRequestException` | | 401 | `UnauthorizedException` | | 403 | `PermissionDeniedException` | | 404 | `NotFoundException` | | 422 | `UnprocessableEntityException` | | 429 | `RateLimitException` | | >=500 | `InternalServerException` | | others | `UnexpectedStatusCodeException` | ```java try { MessageSendResponse response = client.messages().send(params); System.out.println("Sent: " + response.data().recipients().get().get(0).messageId()); } catch (NotFoundException e) { System.err.println("Contact or template not found: " + e.getMessage()); } catch (RateLimitException e) { System.err.println("Rate limited: " + e.getMessage()); } catch (UnauthorizedException e) { System.err.println("Authentication failed - check API key"); } catch (SentException e) { System.err.println("Error: " + e.getMessage()); } ``` ## Raw responses To access response headers, status code, or raw body, prefix any HTTP method call with `withRawResponse()`: ```java import dm.sent.core.http.Headers; import dm.sent.core.http.HttpResponseFor; import dm.sent.models.messages.MessageSendResponse; HttpResponseFor response = client.messages().withRawResponse().send(params); int statusCode = response.statusCode(); Headers headers = response.headers(); // Deserialize if needed MessageSendResponse parsed = response.parse(); ``` ## Contacts Create and manage contacts: ```java import dm.sent.models.contacts.ApiResponseOfContact; import dm.sent.models.contacts.ContactCreateParams; import dm.sent.models.contacts.ContactDeleteParams; import dm.sent.models.contacts.ContactListParams; import dm.sent.models.contacts.ContactListResponse; import dm.sent.models.contacts.ContactUpdateParams; import dm.sent.models.webhooks.MutationRequest; // Create a contact ContactCreateParams createParams = ContactCreateParams.builder() .phoneNumber("+1234567890") .build(); ApiResponseOfContact contact = client.contacts().create(createParams); System.out.println("Contact ID: " + contact.data().id()); // List contacts ContactListParams listParams = ContactListParams.builder() .page(1) .pageSize(100) .build(); ContactListResponse contacts = client.contacts().list(listParams); contacts.data().contacts().forEach(c -> System.out.println(c.phoneNumber() + " - " + c.availableChannels()) ); // Get a contact ApiResponseOfContact retrieved = client.contacts().retrieve("contact-uuid"); // Update a contact ContactUpdateParams updateParams = ContactUpdateParams.builder() .defaultChannel("whatsapp") .build(); ApiResponseOfContact updated = client.contacts().update("contact-uuid", updateParams); // Delete a contact client.contacts().delete( ContactDeleteParams.builder() .id("contact-uuid") .mutationRequest(MutationRequest.builder().build()) .build() ); ``` ## Templates List and retrieve templates: ```java import dm.sent.models.templates.ApiResponseTemplate; import dm.sent.models.templates.TemplateListParams; import dm.sent.models.templates.TemplateListResponse; // List templates TemplateListResponse templates = client.templates().list( TemplateListParams.builder() .page(1) .pageSize(100) .build() ); templates.data().templates().forEach(template -> System.out.println(template.name() + " (" + template.status() + "): " + template.id()) ); // Get a specific template ApiResponseTemplate template = client.templates().retrieve("template-uuid"); System.out.println("Name: " + template.data().name()); System.out.println("Status: " + template.data().status()); ``` ## Framework Integration A dedicated guide covers client configuration, message sending, verified webhook handling, and sandbox testing: ## Client customization To temporarily use a modified client configuration, while reusing the same connection and thread pools, call `withOptions()`: ```java import dm.sent.client.SentClient; SentClient clientWithOptions = client.withOptions(optionsBuilder -> { optionsBuilder.baseUrl("https://example.com"); optionsBuilder.maxRetries(5); }); ``` The `withOptions()` method does not affect the original client. ## Immutability Each class in the SDK has an associated builder for constructing it. Each class is immutable once constructed. If the class has an associated builder, then it has a `toBuilder()` method for making a modified copy. ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("template-id") .build()) .build(); // Create a modified copy MessageSendParams modified = params.toBuilder() .addTo("+0987654321") .build(); ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```java import com.fasterxml.jackson.databind.ObjectMapper; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.Base64; import java.util.Collections; import java.util.Map; @RestController public class WebhookController { private final ObjectMapper objectMapper = new ObjectMapper(); @PostMapping("/webhooks/sent") public ResponseEntity handleWebhook( @RequestBody byte[] payload, // raw bytes — do NOT use @RequestBody String @RequestHeader("X-Webhook-ID") String webhookId, @RequestHeader("X-Webhook-Timestamp") String timestamp, @RequestHeader("X-Webhook-Signature") String signature ) throws Exception { // 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" String secret = System.getenv("SENT_DM_WEBHOOK_SECRET"); // "whsec_abc123..." String keyBase64 = secret.startsWith("whsec_") ? secret.substring(6) : secret; byte[] keyBytes = Base64.getDecoder().decode(keyBase64); String signed = webhookId + "." + timestamp + "." + new String(payload, StandardCharsets.UTF_8); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); String expected = "v1," + Base64.getEncoder().encodeToString( mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)) ); if (!MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) { return ResponseEntity.status(401).body(Collections.singletonMap("error", "Invalid signature")); } // 2. Optional: reject replayed events older than 5 minutes if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300) { return ResponseEntity.status(401).body(Collections.singletonMap("error", "Timestamp too old")); } // 3. Handle events — update message status in your own database Map event = objectMapper.readValue(payload, Map.class); if ("message".equals(event.get("field"))) { Map p = (Map) event.get("payload"); // messageRepository.updateStatus((String) p.get("message_id"), (String) p.get("message_status")); } // 4. Always return 200 quickly return ResponseEntity.ok(Collections.singletonMap("received", true)); } } ``` See the [Webhooks reference](/start/webhooks) for the full payload schema and all status values. ## Source & Issues - **Releases**: [GitHub Releases](https://github.com/sentdm/sent-dm-java/releases) - **GitHub**: [`sentdm/sent-dm-java`](https://github.com/sentdm/sent-dm-java) - **Maven Central**: [dm.sent:sent-java](https://central.sonatype.com/artifact/dm.sent/sent-java) - **Javadoc**: [javadoc.io](https://javadoc.io/doc/dm.sent/sent-java) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-java/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/java/integrations/spring-boot.txt TITLE: Sending messages from Spring Boot with the Sent Java SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/java/integrations/spring-boot.txt Wire the Sent Java SDK into a Spring Boot app: install, register a client bean, send messages from a controller, verify webhooks, and test with sandbox mode. # Sending messages from Spring Boot with the Sent Java SDK This guide shows you how to wire Sent messaging into an existing Spring Boot app: install the Java SDK, register a client bean, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Spring Boot 3.x app and familiarity with beans and controllers. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK dependency to your build: ```xml dm.sent sent-java 0.30.0 ``` For Gradle, use `implementation("dm.sent:sent-java:0.30.0")` instead. ### Configure the client bean Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Register one shared client bean; `fromEnv()` reads `SENT_DM_API_KEY` (or the `sent.dmApiKey` system property): ```java // config/SentConfig.java package com.example.sent.config; import dm.sent.client.SentClient; import dm.sent.client.okhttp.SentOkHttpClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SentConfig { @Bean public SentClient sentClient() { return SentOkHttpClient.fromEnv(); } } ``` ### Send a template message from a controller Define a validated request record; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```java // dto/SendMessageRequest.java package com.example.sent.dto; import jakarta.validation.constraints.*; import java.util.List; import java.util.Map; public record SendMessageRequest( @NotEmpty List<@Pattern(regexp = "^\\+[1-9]\\d{1,14}$") String> to, // E.164 numbers @NotBlank String templateName, // reference by name or id, never both Map parameters, List<@Pattern(regexp = "^(whatsapp|sms|rcs)$") String> channels, boolean sandbox // true = validate and simulate only ) {} ``` Add the controller that builds the params and calls `messages().send`: ```java // controller/MessageController.java package com.example.sent.controller; import com.example.sent.dto.SendMessageRequest; import dm.sent.client.SentClient; import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/messages") public class MessageController { private final SentClient sentClient; public MessageController(SentClient sentClient) { this.sentClient = sentClient; } @PostMapping("/send") public ResponseEntity> sendMessage(@Valid @RequestBody SendMessageRequest request) { var parameters = MessageSendParams.Template.Parameters.builder(); if (request.parameters() != null) { request.parameters().forEach((k, v) -> parameters.putAdditionalProperty(k, JsonValue.from(v))); } var builder = MessageSendParams.builder() .to(request.to()) .template(MessageSendParams.Template.builder() .name(request.templateName()) .parameters(parameters.build()) .build()) .sandbox(request.sandbox()); if (request.channels() != null) { request.channels().forEach(builder::addChannel); // omit to let Sent pick per recipient } MessageSendResponse response = sentClient.messages().send(builder.build()); MessageSendResponse.Data data = response.data().orElseThrow(); var recipient = data.recipients().orElseThrow().get(0); return ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of( "message_id", recipient.messageId().orElse(""), "status", data.status().orElse("") )); } } ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`. Delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Define a record for the event envelope. Every event carries `field`, `event`, `timestamp`, and `payload`, and template events omit `event`: ```java // dto/WebhookEvent.java package com.example.sent.dto; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Instant; @JsonIgnoreProperties(ignoreUnknown = true) public record WebhookEvent(String field, String event, Instant timestamp, Payload payload) { @JsonIgnoreProperties(ignoreUnknown = true) public record Payload( @JsonProperty("message_id") String messageId, @JsonProperty("message_status") String messageStatus, @JsonProperty("inbound_number") String inboundNumber, String channel, String text ) {} } ``` Add a controller that verifies the `X-Webhook-Signature` header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```java // controller/WebhookController.java package com.example.sent.controller; import com.example.sent.dto.WebhookEvent; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Base64; import java.util.Map; @RestController @RequestMapping("/webhooks") public class WebhookController { private static final Logger logger = LoggerFactory.getLogger(WebhookController.class); private final ObjectMapper mapper; private final String webhookSecret; public WebhookController(ObjectMapper mapper, @Value("${SENT_DM_WEBHOOK_SECRET:}") String webhookSecret) { this.mapper = mapper; this.webhookSecret = webhookSecret; } @PostMapping("/sent") public ResponseEntity> handleWebhook( @RequestBody String payload, @RequestHeader(value = "X-Webhook-ID", required = false) String webhookId, @RequestHeader(value = "X-Webhook-Timestamp", required = false) String timestamp, @RequestHeader(value = "X-Webhook-Signature", required = false) String signature) throws Exception { if (!verifySignature(payload, webhookId, timestamp, signature)) { return ResponseEntity.status(401).body(Map.of("error", "invalid signature")); } WebhookEvent event = mapper.readValue(payload, WebhookEvent.class); if ("message".equals(event.field())) { var p = event.payload(); switch (event.event() != null ? event.event() : "") { case "message.delivered" -> logger.info("Message {} delivered", p.messageId()); case "message.failed" -> logger.error("Message {} failed (status {})", p.messageId(), p.messageStatus()); case "message.received" -> logger.info("Inbound {} from {}: {}", p.channel(), p.inboundNumber(), p.text()); default -> logger.info("Message {} status: {}", p.messageId(), p.messageStatus()); } } return ResponseEntity.ok(Map.of("received", true)); } private boolean verifySignature(String payload, String webhookId, String timestamp, String signature) { // Fail closed: never accept webhooks when the secret is not configured if (webhookSecret == null || webhookSecret.isBlank()) return false; if (webhookId == null || timestamp == null || signature == null) return false; try { // Strip the "whsec_" prefix and base64-decode to get the raw HMAC key String keyBase64 = webhookSecret.startsWith("whsec_") ? webhookSecret.substring(6) : webhookSecret; byte[] keyBytes = Base64.getDecoder().decode(keyBase64); // Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" String signed = webhookId + "." + timestamp + "." + payload; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); String expected = "v1," + Base64.getEncoder().encodeToString( mac.doFinal(signed.getBytes(StandardCharsets.UTF_8))); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { return false; } } } ``` Keep this endpoint outside Spring Security's authenticated routes, because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Spring Boot integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash ./mvnw spring-boot:run ``` Send a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:8080/api/messages/send \ -H "Content-Type: application/json" \ -d '{"to": ["+14155551234"], "templateName": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your application log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If webhook processing does slow work (database writes, downstream calls), verify the signature synchronously, return 200, and hand the event to an `@Async` executor so retries do not pile up. The appendix below has the executor; see also [handling webhook retries](/start/webhooks/handling-retries). - If you process each event exactly once, derive an idempotency key from the event sub-type plus `message_id` and skip duplicates. The appendix shows the pattern. - To send to many recipients, pass them all in `to`. Sent creates one message per recipient-and-channel pair in a single call. - To send free-form text instead of a template, use `.text(...)` instead of `.template(...)`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Spring Boot stack. Adapt them to your own conventions rather than adopting them wholesale. Process events on a dedicated thread pool after acknowledging the delivery: ```java // config/AsyncConfig.java @Configuration @EnableAsync public class AsyncConfig { @Bean(name = "webhookTaskExecutor") public Executor webhookTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("webhook-"); executor.initialize(); return executor; } } ``` Annotate the processing method with `@Async("webhookTaskExecutor")`. Sent events carry no unique event ID. Derive an idempotency key instead: message events use the sub-type plus `message_id` (each status transition fires once per message); events without a `message_id` fall back to a hash of the raw body, which is identical across delivery retries: ```java private static String buildEventId(String eventType, WebhookEvent event, String rawBody) { if (event.payload() != null && event.payload().messageId() != null) { return eventType + ":" + event.payload().messageId(); } try { byte[] hash = MessageDigest.getInstance("SHA-256") .digest(rawBody.getBytes(StandardCharsets.UTF_8)); return eventType + ":" + HexFormat.of().formatHex(hash); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 unavailable", e); } } ``` Store the key in a unique column and skip events that already exist. Return RFC 7807 problem details for validation and security failures: ```java // exception/GlobalExceptionHandler.java @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); problem.setTitle("Validation Failed"); return problem; } } ``` The [SDK testing guide](/sdks/testing) covers mocking `SentClient` in controller tests. ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Java SDK reference](/sdks/java) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/php.txt TITLE: PHP SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/php.txt Official PHP SDK for Sent. Elegant syntax with full Laravel and Symfony integration. # PHP SDK The official PHP SDK for Sent provides a clean, object-oriented interface for sending messages. Built with modern PHP 8.1+ features. ## Requirements PHP 8.1.0 or higher. ## Installation ```bash composer require sentdm/sent-dm-php ``` To install a specific version: ```bash composer require "sentdm/sent-dm-php 0.26.0" ``` ## Quick Start ### Initialize the client ```php messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome', 'parameters' => [ 'name' => 'John Doe', 'order_id' => '12345' ] ], channel: ['sms', 'whatsapp', 'rcs'] // Optional ); var_dump($result->data->recipients[0]->messageID); var_dump($result->data->status); ``` ## Authentication The client accepts an API key as the first parameter. ```php use SentDm\Client; // Using API key directly $client = new Client('your_api_key'); // Or from environment variable $client = new Client($_ENV['SENT_DM_API_KEY']); ``` ## Send Messages This library uses named parameters to specify optional arguments. Parameters with a default value must be set by name. ### Send a message ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome', 'parameters' => [ 'name' => 'John Doe', 'order_id' => '12345' ] ], channel: ['sms', 'whatsapp', 'rcs'] ); var_dump($result->data->recipients[0]->messageID); var_dump($result->data->status); ``` ### Sandbox mode Use `sandbox: true` to validate requests without sending real messages: ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], sandbox: true // Validates but doesn't send ); // Response will have test data var_dump($result->data->recipients[0]->messageID); var_dump($result->data->status); ``` ## Check message status Retrieve the current status of a sent message. The `direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```php $status = $client->messages->retrieveStatus('msg-uuid'); var_dump($status->data->status); // e.g. "DELIVERED" var_dump($status->data->channel); // e.g. "sms" var_dump($status->data->direction); // "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```php $activities = $client->messages->retrieveActivities('msg-uuid'); foreach ($activities->data->activities as $activity) { echo $activity->timestamp . ': ' . $activity->status . ' via ' . $activity->from . "\n"; echo ' Price: ' . $activity->price . "\n"; echo ' Active contact price: ' . $activity->activeContactPrice . "\n"; } ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```php $result = $client->numbers->lookup('+12025551234'); var_dump($result->data->isValid); // bool var_dump($result->data->carrierName); // e.g. "T-Mobile" var_dump($result->data->lineType); // "mobile", "landline", "voip" var_dump($result->data->isVoip); // bool ``` ## Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (that is, 4xx or 5xx response), a subclass of `SentDm\Core\Exceptions\APIException` will be thrown: ```php use SentDm\Core\Exceptions\APIConnectionException; use SentDm\Core\Exceptions\RateLimitException; use SentDm\Core\Exceptions\APIStatusException; try { $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ] ); } catch (APIConnectionException $e) { echo "The server could not be reached", PHP_EOL; var_dump($e->getPrevious()); } catch (RateLimitException $e) { echo "A 429 status code was received; we should back off a bit.", PHP_EOL; } catch (APIStatusException $e) { echo "Another non-200-range status code was received", PHP_EOL; echo $e->getMessage(); } ``` Error codes are as follows: | Cause | Error Type | |-------|------------| | HTTP 400 | `BadRequestException` | | HTTP 401 | `AuthenticationException` | | HTTP 403 | `PermissionDeniedException` | | HTTP 404 | `NotFoundException` | | HTTP 409 | `ConflictException` | | HTTP 422 | `UnprocessableEntityException` | | HTTP 429 | `RateLimitException` | | HTTP >= 500 | `InternalServerException` | | Other HTTP error | `APIStatusException` | | Timeout | `APITimeoutException` | | Network error | `APIConnectionException` | ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. ```php // Configure the default for all requests: $client = new Client($_ENV['SENT_DM_API_KEY'], requestOptions: ['maxRetries' => 0]); // Or, configure per-request: $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], requestOptions: ['maxRetries' => 5], ); ``` ## Value Objects Sent recommends the static `with` constructor and named parameters to initialize value objects. ```php use SentDm\Models\TemplateDefinition; $definition = TemplateDefinition::with( body: [...], header: [...], ); ``` However, builders are also provided: ```php $definition = (new TemplateDefinition)->withBody([...]); ``` ## Contacts Create and manage contacts: ```php // Create a contact $result = $client->contacts->create(phoneNumber: '+1234567890'); var_dump($result->data->id); // List contacts $result = $client->contacts->list(page: 1, pageSize: 100); foreach ($result->data->contacts as $contact) { echo $contact->phoneNumber . " - " . $contact->availableChannels . "\n"; } // Get a contact $result = $client->contacts->retrieve('contact-uuid'); // Update a contact $result = $client->contacts->update('contact-uuid', defaultChannel: 'whatsapp'); // Delete a contact $client->contacts->delete('contact-uuid'); ``` ## Templates List and retrieve templates: ```php // List templates $result = $client->templates->list(page: 1, pageSize: 100); foreach ($result->data->templates as $template) { echo $template->name . " (" . $template->status . "): " . $template->id . "\n"; echo " Category: " . $template->category . "\n"; } // Get a specific template $result = $client->templates->retrieve('template-uuid'); echo "Name: " . $result->data->name . "\n"; echo "Status: " . $result->data->status . "\n"; ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. The following example uses plain PHP (`php://input` and `$_SERVER`) and works in any PHP app: ```php 'Invalid signature']); exit; } // 3. Optional: reject replayed events older than 5 minutes if (abs(time() - intval($timestamp)) > 300) { http_response_code(401); echo json_encode(['error' => 'Timestamp too old']); exit; } $event = json_decode($payload); // 4. Handle events — update message status in your own database if ($event->field === 'message') { if ($event->event === 'message.received') { // Inbound message from a contact: // $event->payload->inbound_number, $event->payload->text, $event->payload->channel } else { // Outbound status update: // $event->payload->message_id, $event->payload->message_status } } // 5. Always return 200 quickly http_response_code(200); echo json_encode(['received' => true]); ``` Framework-specific handlers are available in the [Laravel integration guide](/sdks/php/integrations/laravel) and the [Symfony integration guide](/sdks/php/integrations/symfony). See the [Webhooks reference](/start/webhooks) for the full payload schema and all status values. ## Making custom or undocumented requests ### Undocumented properties You can send undocumented parameters to any endpoint using the `extra*` parameters: ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], requestOptions: [ 'extraQueryParams' => ['my_query_parameter' => 'value'], 'extraBodyParams' => ['my_body_parameter' => 'value'], 'extraHeaders' => ['my-header' => 'value'], ], ); ``` ### Undocumented endpoints To make requests to undocumented endpoints while retaining the benefit of auth, retries, and so on: ```php $response = $client->request( method: 'post', path: '/undocumented/endpoint', query: ['dog' => 'woof'], headers: ['useful-header' => 'interesting-value'], body: ['hello' => 'world'] ); ``` ## Framework Integration Dedicated guides cover client setup, message sending, verified webhook handling, and testing for each framework: ## Source & Issues - **Version**: 0.26.0 - **GitHub**: [`sentdm/sent-dm-php`](https://github.com/sentdm/sent-dm-php) - **Packagist**: [`sentdm/sent-dm-php`](https://packagist.org/packages/sentdm/sent-dm-php) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-php/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/php/integrations/laravel.txt TITLE: Sending messages from Laravel with the Sent PHP SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/php/integrations/laravel.txt Wire the Sent PHP SDK into a Laravel app: install, bind the client in a provider, send messages from a controller, verify webhooks, and test with sandbox mode. # Sending messages from Laravel with the Sent PHP SDK This guide shows you how to wire Sent messaging into an existing Laravel app: install the PHP SDK, bind a shared client in the container, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Laravel 11 app and familiarity with controllers, middleware, and the service container. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing project: ```bash composer require sentdm/sent-dm-php ``` ### Configure and bind the client Add the credentials to `.env`; the webhook secret arrives in step 4: ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secret ``` Expose them through a config file so `config:cache` works in production: ```php env('SENT_DM_API_KEY'), 'webhook_secret' => env('SENT_DM_WEBHOOK_SECRET'), ]; ``` Bind one shared client in the container: ```php app->singleton(Client::class, function ($app) { $apiKey = $app['config']['sent-dm.api_key']; if (empty($apiKey)) { throw new \InvalidArgumentException('Sent API key not configured.'); } return new Client(apiKey: $apiKey); }); ``` ### Send a template message from a controller Add a controller that calls `messages->send`; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```php validate([ 'phone_number' => ['required', 'regex:/^\+[1-9]\d{1,14}$/'], // E.164 format 'template_name' => ['required', 'string', 'max:100'], 'parameters' => ['array'], 'channels' => ['array'], 'sandbox' => ['boolean'], ]); $response = $this->client->messages->send( to: [$validated['phone_number']], template: [ 'name' => $validated['template_name'], // reference by name or id, never both 'parameters' => $validated['parameters'] ?? [], ], channel: $validated['channels'] ?? null, // omit to let Sent pick per recipient sandbox: $validated['sandbox'] ?? false, // true = validate and simulate only ); $recipient = $response->data->recipients[0]; return response()->json([ 'message_id' => $recipient->messageID, 'status' => $response->data->status, ], 202); } } ``` Route it behind your existing API authentication: ```php post('/messages/send', [MessageController::class, 'send']); ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`. Delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add middleware that verifies the `X-Webhook-Signature` header against the raw request body before any handler runs. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```php header('X-Webhook-Signature', ''); $secret = config('sent-dm.webhook_secret'); if (empty($secret)) { Log::error('Webhook secret not configured'); return response()->json(['error' => 'Webhook not configured'], 500); } if (empty($signature)) { return response()->json(['error' => 'Missing signature'], 401); } $payload = $request->getContent(); $webhookId = $request->header('X-Webhook-ID', ''); $timestamp = $request->header('X-Webhook-Timestamp', ''); // Strip the "whsec_" prefix and base64-decode to get the raw HMAC key $keyBase64 = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret; $keyBytes = base64_decode($keyBase64); // Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" $signed = "{$webhookId}.{$timestamp}.{$payload}"; $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true)); if (!hash_equals($expected, $signature)) { Log::warning('Invalid webhook signature', ['ip' => $request->ip()]); return response()->json(['error' => 'Invalid signature'], 401); } return $next($request); } } ``` Register the alias in `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->alias(['sent.webhook' => \App\Http\Middleware\VerifySentWebhook::class]); }) ``` Add the controller. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; return 200 quickly and do slow work in a queued job: ```php getContent(), true) ?? []; $subType = $event['event'] ?? null; // omitted for template events $payload = $event['payload'] ?? []; if (($event['field'] ?? null) === 'message') { match ($subType) { 'message.delivered' => Log::info("Message {$payload['message_id']} delivered"), 'message.failed' => Log::error("Message {$payload['message_id']} failed", ['status' => $payload['message_status'] ?? null]), 'message.received' => Log::info("Inbound message from {$payload['inbound_number']}", ['text' => $payload['text'] ?? null]), default => Log::info("Message {$payload['message_id']} status: " . ($payload['message_status'] ?? $subType)), }; } return response()->json(['received' => true]); } } ``` Route it with the verification middleware and without session authentication, because Sent authenticates with the signature: ```php middleware('sent.webhook'); ``` Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Laravel integration", "endpoint_url": "https://your-domain.example/api/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash php artisan serve ``` Send a sandbox message through your new endpoint (include your API token if the route requires it). Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:8000/api/messages/send \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"phone_number": "+14155551234", "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 422 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your Laravel log (`storage/logs/laravel.log`) should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - Sent retries non-2xx webhook responses with backoff, so make processing idempotent. Keying status updates on `payload.message_id` achieves this naturally; see [handling webhook retries](/start/webhooks/handling-retries). - If webhook processing does slow work (database writes, notifications), dispatch a queued job from the controller and return 200 immediately. The appendix below has the job. - If you audit webhook traffic, persist each envelope in a `webhook_logs` table before processing. The appendix has the migration. - To send free-form text instead of a template, pass `text` instead of `template`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Laravel stack. Adapt them to your own conventions rather than adopting them wholesale. Persist every event for replay and debugging: ```php id(); $table->string('event_type', 100)->index(); // e.g. "message.delivered" $table->uuid('message_id')->nullable()->index(); $table->string('status', 50)->nullable(); $table->json('payload'); $table->timestamp('processed_at')->nullable(); $table->timestamps(); }); ``` ```php 'array', 'processed_at' => 'datetime']; } ``` Move slow processing off the webhook request path: ```php event['payload'] ?? []; // Update your own records keyed by $payload['message_id'] Log::info('Processed Sent event', ['event' => $this->event['event'] ?? null]); } } ``` In the webhook controller, replace the `match` block with `ProcessSentEvent::dispatch($event);`. Cap how often each user can trigger sends: ```php [ Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()), ]); ``` Apply it with `->middleware('throttle:sent-dm')` on the send route. The [SDK testing guide](/sdks/testing) covers faking the client in feature tests. ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [PHP SDK reference](/sdks/php) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/php/integrations/symfony.txt TITLE: Sending messages from Symfony with the Sent PHP SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/php/integrations/symfony.txt Wire the Sent PHP SDK into a Symfony app: install, register the client service, send messages from a controller, verify webhooks, and test with sandbox mode. # Sending messages from Symfony with the Sent PHP SDK This guide shows you how to wire Sent messaging into an existing Symfony app: install the PHP SDK, register a shared client service, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Symfony 7 app and familiarity with controllers, services, and attributes. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing project: ```bash composer require sentdm/sent-dm-php ``` ### Register the client service Add the credentials to `.env.local`; the webhook secret arrives in step 4: ```bash # .env.local SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secret ``` Register one shared client so it autowires into any service or controller: ```yaml # config/services.yaml (additions) services: SentDm\Client: arguments: $apiKey: '%env(SENT_DM_API_KEY)%' ``` ### Send a template message from a controller Define a validated payload DTO; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```php // src/Dto/SendMessageDto.php namespace App\Dto; use Symfony\Component\Validator\Constraints as Assert; class SendMessageDto { #[Assert\NotBlank] #[Assert\Regex(pattern: '/^\+[1-9]\d{1,14}$/', message: 'Phone number must be in E.164 format')] public string $phoneNumber; #[Assert\NotBlank] #[Assert\Length(min: 1, max: 100)] public string $templateName; public ?array $parameters = null; #[Assert\All([new Assert\Choice(choices: ['sms', 'whatsapp', 'rcs'])])] public ?array $channels = null; public bool $sandbox = false; // true = validate and simulate only } ``` Add the controller that maps the payload and calls `messages->send`: ```php // src/Controller/MessagesController.php namespace App\Controller; use App\Dto\SendMessageDto; use SentDm\Client; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; #[Route('/api/messages')] class MessagesController extends AbstractController { public function __construct(private readonly Client $client) {} #[Route('/send', methods: ['POST'])] public function send(#[MapRequestPayload] SendMessageDto $dto): JsonResponse { $response = $this->client->messages->send( to: [$dto->phoneNumber], template: [ 'name' => $dto->templateName, // reference by name or id, never both 'parameters' => $dto->parameters ?? [], ], channel: $dto->channels, // omit to let Sent pick per recipient sandbox: $dto->sandbox, ); $recipient = $response->data->recipients[0]; return $this->json([ 'message_id' => $recipient->messageID, 'status' => $response->data->status, ], 202); } } ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`. Delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a webhook controller that verifies the `X-Webhook-Signature` header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them: ```php // src/Controller/WebhookController.php namespace App\Controller; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; class WebhookController { public function __construct( #[Autowire(env: 'SENT_DM_WEBHOOK_SECRET')] private readonly ?string $webhookSecret, private readonly LoggerInterface $logger, ) {} #[Route('/webhooks/sent', methods: ['POST'])] public function handle(Request $request): JsonResponse { if (empty($this->webhookSecret)) { // Fail closed: never accept webhooks without a configured secret return new JsonResponse(['error' => 'Webhook not configured'], 500); } $signature = $request->headers->get('X-Webhook-Signature'); if (!$signature) { return new JsonResponse(['error' => 'Missing signature'], 401); } $payload = $request->getContent(); $webhookId = $request->headers->get('X-Webhook-ID', ''); $timestamp = $request->headers->get('X-Webhook-Timestamp', ''); // Strip the "whsec_" prefix and base64-decode to get the raw HMAC key $keyBase64 = str_starts_with($this->webhookSecret, 'whsec_') ? substr($this->webhookSecret, 6) : $this->webhookSecret; $keyBytes = base64_decode($keyBase64); // Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" $signed = "{$webhookId}.{$timestamp}.{$payload}"; $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true)); if (!hash_equals($expected, $signature)) { $this->logger->warning('Invalid webhook signature', ['ip' => $request->getClientIp()]); return new JsonResponse(['error' => 'Invalid signature'], 401); } $event = json_decode($payload, true) ?? []; $subType = $event['event'] ?? null; // omitted for template events $data = $event['payload'] ?? []; if (($event['field'] ?? null) === 'message') { match ($subType) { 'message.delivered' => $this->logger->info('Message delivered', ['message_id' => $data['message_id'] ?? null]), 'message.failed' => $this->logger->error('Message failed', ['message_id' => $data['message_id'] ?? null, 'status' => $data['message_status'] ?? null]), 'message.received' => $this->logger->info('Inbound message', ['from' => $data['inbound_number'] ?? null, 'text' => $data['text'] ?? null]), default => $this->logger->info('Message status updated', ['message_id' => $data['message_id'] ?? null, 'status' => $data['message_status'] ?? null]), }; } return new JsonResponse(['received' => true]); } } ``` Keep this route outside your firewall's authenticated area (`config/packages/security.yaml`), because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Symfony integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash symfony server:start ``` Send a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:8000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155551234", "templateName": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 422 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your application log should show a `Message delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - Sent retries non-2xx webhook responses with backoff, so make processing idempotent. Keying status updates on `payload.message_id` achieves this naturally; see [handling webhook retries](/start/webhooks/handling-retries). - If you send outside the request cycle, dispatch a Messenger command and let a worker call the API. The appendix below has the pattern. - If you rate-limit send endpoints, use Symfony's `rate_limiter` component. The appendix has the configuration. - To send free-form text instead of a template, pass `text` instead of `template`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Symfony stack. Adapt them to your own conventions rather than adopting them wholesale. Move the API call out of the request cycle: ```php // src/Message/SendMessageCommand.php namespace App\Message; final readonly class SendMessageCommand { public function __construct( public string $phoneNumber, public string $templateName, public array $parameters = [], public ?array $channels = null, ) {} } ``` ```php // src/MessageHandler/SendMessageHandler.php namespace App\MessageHandler; use App\Message\SendMessageCommand; use SentDm\Client; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final readonly class SendMessageHandler { public function __construct(private Client $client) {} public function __invoke(SendMessageCommand $command): void { $this->client->messages->send( to: [$command->phoneNumber], template: ['name' => $command->templateName, 'parameters' => $command->parameters], channel: $command->channels, ); } } ``` Dispatch with `$bus->dispatch(new SendMessageCommand(...))` and run a worker with `php bin/console messenger:consume`. Cap how often each user can trigger sends: ```yaml # config/packages/rate_limiter.yaml framework: rate_limiter: sent_message: policy: 'sliding_window' limit: 100 interval: '1 minute' ``` Inject `RateLimiterFactory $sentMessageLimiter` into the controller and call `$limiter->consume(1)->isAccepted()` before sending. This PHPUnit helper signs payloads exactly the way Sent does, so you can test verification offline; the [SDK testing guide](/sdks/testing) covers mocking the client: ```php // tests/Controller/WebhookControllerTest.php (excerpt) private function signedHeaders(string $payload, string $secret): array { $webhookId = '550e8400-e29b-41d4-a716-446655440000'; $timestamp = (string) time(); $keyBytes = base64_decode(substr($secret, 6)); // strip "whsec_" $signed = "{$webhookId}.{$timestamp}.{$payload}"; $signature = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true)); return [ 'HTTP_X-Webhook-ID' => $webhookId, 'HTTP_X-Webhook-Timestamp' => $timestamp, 'HTTP_X-Webhook-Signature' => $signature, ]; } ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [PHP SDK reference](/sdks/php) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python.txt TITLE: Python SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/python.txt Official Python SDK for Sent. Send SMS, WhatsApp, and RCS messages with Pythonic elegance and full async support. # Python SDK The official Python SDK for Sent provides a clean, Pythonic interface to the Sent API. Built for developers who value readability, with optional async support for high-performance applications. ## Requirements This library requires Python 3.9 or later. ## Installation ```bash pip install sentdm ``` ```bash poetry add sentdm ``` ```bash uv pip install sentdm ``` ## Quick Start ### Initialize the client ```python from sent_dm import Sent client = Sent() # Uses SENT_DM_API_KEY env var by default ``` ### Send your first message ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": { "name": "John Doe" } } ) print(f"Message sent: {response.data.recipients[0].message_id}") print(f"Status: {response.data.status}") ``` ## Authentication While you can provide an `api_key` keyword argument, Sent recommends using `python-dotenv` to add `SENT_DM_API_KEY="your_api_key"` to your `.env` file so that your API Key is not stored in source control. ```python from sent_dm import Sent # Using environment variables (recommended) client = Sent() # Or explicit configuration client = Sent( api_key="your_api_key", ) ``` ## Async usage Import `AsyncSent` instead of `Sent` and use `await` with each API call: ```python import asyncio from sent_dm import AsyncSent client = AsyncSent() async def main(): response = await client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": { "name": "John Doe" } } ) print(f"Sent: {response.data.recipients[0].message_id}") asyncio.run(main()) ``` Functionality between the synchronous and asynchronous clients is otherwise identical. ### With aiohttp By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. ```bash pip install sentdm[aiohttp] ``` ```python import asyncio from sent_dm import DefaultAioHttpClient from sent_dm import AsyncSent async def main(): async with AsyncSent( http_client=DefaultAioHttpClient(), ) as client: response = await client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": {"name": "John Doe"} } ) print(f"Sent: {response.data.recipients[0].message_id}") asyncio.run(main()) ``` ## Send Messages ### Send a message ```python from sent_dm import Sent client = Sent() response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": { "name": "John Doe", "order_id": "12345" } }, channel=["whatsapp", "sms", "rcs"] # Optional. Defaults to ["sent"], which auto-detects the channel per recipient ) print(f"Message ID: {response.data.recipients[0].message_id}") print(f"Status: {response.data.status}") ``` ### Sandbox mode Use `sandbox=True` to validate requests without sending real messages: ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" }, sandbox=True # Validates but doesn't send ) # Response will have test data print(f"Validation passed: {response.data.recipients[0].message_id}") ``` ## Check message status Retrieve the current status of a sent message. The `direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```python status = client.messages.retrieve_status("msg-uuid") print(f"Status: {status.data.status}") # e.g. "DELIVERED" print(f"Channel: {status.data.channel}") # e.g. "sms" print(f"Direction: {status.data.direction}") # "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```python activities = client.messages.retrieve_activities("msg-uuid") for activity in activities.data.activities: print(f"{activity.timestamp}: {activity.status} via {activity.from_}") print(f" Price: {activity.price}") print(f" Active contact price: {activity.active_contact_price}") ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```python result = client.numbers.lookup("+12025551234") print(f"Valid: {result.data.is_valid}") print(f"Carrier: {result.data.carrier_name}") print(f"Line type: {result.data.line_type}") # "mobile", "landline", "voip" print(f"VoIP: {result.data.is_voip}") ``` ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `sent_dm.APIConnectionError` is raised. When the API returns a non-success status code (that is, 4xx or 5xx response), a subclass of `sent_dm.APIStatusError` is raised, containing `status_code` and `response` properties. ```python import sent_dm from sent_dm import Sent client = Sent() try: response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(f"Sent: {response.data.recipients[0].message_id}") except sent_dm.APIConnectionError as e: print("The server could not be reached") print(e.__cause__) # an underlying Exception, likely raised within httpx. except sent_dm.RateLimitError as e: print("A 429 status code was received; we should back off a bit.") except sent_dm.APIStatusError as e: print("Another non-200-range status code was received") print(e.status_code) print(e.response) ``` Error codes are as follows: | Status Code | Error Type | |-------------|--------------------------| | 400 | `BadRequestError` | | 401 | `AuthenticationError` | | 403 | `PermissionDeniedError` | | 404 | `NotFoundError` | | 422 | `UnprocessableEntityError` | | 429 | `RateLimitError` | | >=500 | `InternalServerError` | | N/A | `APIConnectionError` | ## Retries Certain errors are automatically retried 2 times by default, with a short exponential backoff. Connection errors, 408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errors are all retried by default. ```python from sent_dm import Sent # Configure the default for all requests: client = Sent( max_retries=0, # default is 2 ) # Or, configure per-request: client.with_options(max_retries=5).messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) ``` ## Timeouts By default requests time out after 1 minute. You can configure this with a `timeout` option: ```python import httpx from sent_dm import Sent # Configure the default for all requests: client = Sent( timeout=20.0, # 20 seconds (default is 1 minute) ) # More granular control: client = Sent( timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), ) # Override per-request: client.with_options(timeout=5.0).messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) ``` ## Contacts Create and manage contacts: ```python # Create a contact response = client.contacts.create( phone_number="+1234567890" ) print(f"Contact ID: {response.data.id}") # List contacts responses = client.contacts.list(page=1, page_size=100) for contact in responses.data.contacts: print(f"{contact.phone_number} - {contact.available_channels}") # Get a contact response = client.contacts.retrieve("contact-uuid") # Update a contact response = client.contacts.update( "contact-uuid", default_channel="whatsapp" ) # Delete a contact client.contacts.delete("contact-uuid") ``` ## Templates List and retrieve templates: ```python # List all templates response = client.templates.list(page=1, page_size=100) for template in response.data.templates: print(f"{template.name} ({template.status}): {template.id}") # Get a specific template response = client.templates.retrieve("template-uuid") print(f"Name: {response.data.name}") print(f"Status: {response.data.status}") ``` ## Framework Integration Dedicated guides cover client setup, message sending, verified webhook handling, and sandbox testing for each framework: ## Raw responses To access response headers, status code, or raw body, prefix any HTTP method call with `.with_raw_response.`: ```python response = client.with_raw_response.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(response.status_code) # 200 print(response.headers.get("x-request-id")) # Request ID for support # Deserialize the body data = response.parse() print(data.data.recipients[0].message_id) ``` ## Streaming responses The async client supports streaming responses for large data: ```python from sent_dm import AsyncSent client = AsyncSent() async with client.messages.with_streaming_response.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) as response: async for chunk in response.iter_bytes(): print(chunk) ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```python import base64 import hashlib import hmac import json import os import time from flask import Flask, request, jsonify app = Flask(__name__) @app.post('/webhooks/sent') def handle_webhook(): payload = request.get_data() # raw bytes — do NOT parse JSON first webhook_id = request.headers.get('X-Webhook-ID', '') timestamp = request.headers.get('X-Webhook-Timestamp', '') signature = request.headers.get('X-Webhook-Signature', '') # 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" secret = os.environ['SENT_DM_WEBHOOK_SECRET'] # "whsec_abc123..." key_bytes = base64.b64decode(secret.removeprefix('whsec_')) signed = f"{webhook_id}.{timestamp}.{payload.decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest() expected = 'v1,' + base64.b64encode(digest).decode() if not hmac.compare_digest(signature, expected): return jsonify({'error': 'Invalid signature'}), 401 # 2. Optional: reject replayed events older than 5 minutes if abs(time.time() - int(timestamp)) > 300: return jsonify({'error': 'Timestamp too old'}), 401 event = json.loads(payload) # 3. Handle events — update message status in your own database if event.get('field') == 'message': event_type = event.get('event') msg = event['payload'] if event_type == 'message.received': # Inbound message from a contact print(f"Inbound {msg['channel']} from {msg['inbound_number']}: {msg.get('text')}") # db.inbound_messages.create(from_number=msg['inbound_number'], text=msg.get('text'), ...) else: # Outbound message status update # db.messages.filter(sent_id=msg['message_id']).update(status=msg['message_status']) print(f"Message {msg['message_id']} → {msg['message_status']}") # 4. Always return 200 quickly return jsonify({'received': True}) ``` See the [Webhooks reference](/start/webhooks) for the full payload schema and all status values. ## Logging The SDK uses the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. You can enable logging by setting the environment variable `SENT_LOG` to `info`: ```bash export SENT_LOG=info ``` Or to `debug` for more verbose logging. ## Source & Issues - **Releases**: [GitHub Releases](https://github.com/sentdm/sent-dm-python/releases) - **GitHub**: [`sentdm/sent-dm-python`](https://github.com/sentdm/sent-dm-python) - **PyPI**: [`sentdm`](https://pypi.org/project/sentdm/) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-python/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/celery.txt TITLE: Sending Sent messages from Celery background tasks ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/celery.txt Send Sent messages from Celery tasks: install the Python SDK, configure a worker-safe client, add a retrying send task, and verify the task with sandbox mode. # Sending Sent messages from Celery background tasks This guide shows you how to move Sent message sending into Celery: install the Python SDK, share one client per worker process, add a send task with retry handling, process webhook events off the request path, and verify the task in sandbox mode. ## Prerequisites This guide assumes a working Celery setup (broker running, worker starting) inside a Python web project. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A web framework endpoint for webhooks. The [Flask](/sdks/python/integrations/flask), [Django](/sdks/python/integrations/django), and [FastAPI](/sdks/python/integrations/fastapi) guides each build one; Celery workers do not receive HTTP requests themselves ### Install the SDK Add the `sentdm` package to the environment your workers run in: ```bash pip install sentdm ``` ### Configure a worker-safe client Export the API key where the worker can read it: ```bash export SENT_DM_API_KEY="your-api-key" ``` Create the client lazily inside a base task class, so each worker process builds exactly one client after forking instead of sharing one across processes: ```python # celery_app/base_task.py import os from celery import Task class SentTask(Task): abstract = True max_retries = 3 default_retry_delay = 60 retry_backoff = True # 60s, 120s, 240s, ... between retries retry_jitter = True _sent_client = None @property def sent_client(self): if self._sent_client is None: from sent_dm import Sent api_key = os.getenv("SENT_DM_API_KEY") if not api_key: raise RuntimeError("SENT_DM_API_KEY not configured") self._sent_client = Sent(api_key=api_key) return self._sent_client ``` ### Add a send task Write the task that calls `messages.send`. Retry on transient failures, but never on validation errors (a request that was invalid once is invalid every time): ```python # tasks/messages.py import logging from celery import shared_task from sent_dm import APIStatusError, APIConnectionError, RateLimitError from celery_app.base_task import SentTask logger = logging.getLogger(__name__) @shared_task(base=SentTask, bind=True, name="tasks.messages.send_message", queue="messages") def send_message(self, phone_number: str, template_name: str, parameters: dict | None = None, channels: list | None = None, sandbox: bool = False) -> dict: try: response = self.sent_client.messages.send( to=[phone_number], # E.164 format, for example +14155551234 template={ "name": template_name, # reference by "name" or "id", never both "parameters": parameters or {}, }, channel=channels, # omit to let Sent pick per recipient sandbox=sandbox, # True = validate and simulate only ) except RateLimitError as exc: # Back off and retry when the API rate limit is hit raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries)) except APIConnectionError as exc: # Network problems are transient: retry with backoff raise self.retry(exc=exc) except APIStatusError as exc: if exc.status_code and exc.status_code < 500: logger.error("Non-retryable send failure (%s): %s", exc.status_code, exc) raise # 4xx: the request itself is wrong; retrying cannot fix it raise self.retry(exc=exc) recipient = response.data.recipients[0] logger.info("Message queued: %s", recipient.message_id) return {"message_id": recipient.message_id, "status": response.data.status} ``` Call it from your web code with `.delay(...)`. The request returns immediately and the worker does the sending: ```python send_message.delay("+14155551234", "welcome", {"name": "Ada"}) ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id` from the task result, since delivery outcomes arrive as webhooks rather than in this response. ### Process webhook events off the request path Keep your webhook endpoint fast by acknowledging immediately and handing the verified event envelope (`field`, `event`, `timestamp`, `payload`) to a task. Signature verification must stay in the web endpoint, where the raw request bytes are available. See [webhook signature verification](/start/webhooks/signature-verification): ```python # tasks/webhooks.py import logging from celery import shared_task logger = logging.getLogger(__name__) @shared_task(name="tasks.webhooks.process_event", queue="webhooks") def process_event(event: dict) -> None: payload = event.get("payload", {}) if event.get("field") != "message": return match event.get("event"): case "message.delivered": logger.info("Message %s delivered", payload.get("message_id")) case "message.failed": logger.error("Message %s failed (status %s)", payload.get("message_id"), payload.get("message_status")) case "message.received": logger.info("Inbound %s from %s: %s", payload.get("channel"), payload.get("inbound_number"), payload.get("text")) case _: logger.info("Message %s status: %s", payload.get("message_id"), payload.get("message_status")) ``` In the webhook endpoint from your framework guide, replace the inline processing with one line after verification succeeds: ```python process_event.delay(event) ``` ### Verify the task Start a worker on the messages queue: ```bash celery -A celery_app.app worker --queues=messages --loglevel=info ``` Trigger a sandbox send from a shell. Full validation runs, but nothing is delivered and no credits are consumed: ```python # python manage.py shell, flask shell, or plain python from tasks.messages import send_message result = send_message.delay("+14155551234", "welcome", {"name": "Ada"}, sandbox=True) print(result.get(timeout=30)) ``` The result should contain a `message_id` and `"status": "QUEUED"`, and the worker log should show a `Message queued: ...` line. A `BadRequestError` in the worker log means the request shape is wrong: sandbox requests return real validation errors. If the task never runs, confirm the worker is consuming the `messages` queue named in the task decorator. ## Adapt this to your app - If you send campaigns to large lists, fan out one task per recipient rather than looping inside a single task. The appendix below shows the batch pattern. - If you schedule recurring sends, drive them with Celery beat and call the same `send_message` task from the schedule. - If your workers hit API rate limits under load, lower worker concurrency for the `messages` queue or set a Celery `rate_limit` on the task before adding custom throttling. - To send free-form text instead of a template, pass `text` instead of `template`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Celery deployment. Adapt them to your own conventions rather than adopting them wholesale. Route message traffic to dedicated queues so a bulk campaign cannot starve transactional sends: ```python # celery_app/config.py import os class CeleryConfig: broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") result_backend = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") task_serializer = "json" accept_content = ["json"] task_acks_late = True # re-deliver if a worker dies mid-task worker_prefetch_multiplier = 1 # fair scheduling for slow API-bound tasks task_time_limit = 300 task_soft_time_limit = 240 task_routes = { "tasks.messages.*": {"queue": "messages"}, "tasks.webhooks.*": {"queue": "webhooks"}, } ``` Queue one task per recipient so retries and failures stay per-message: ```python # tasks/bulk.py from celery import shared_task from tasks.messages import send_message @shared_task(name="tasks.bulk.send_batch", queue="messages") def send_batch(recipients: list[dict], template_name: str) -> dict: queued, failed = 0, [] for r in recipients: try: send_message.delay(r["phone_number"], template_name, r.get("parameters")) queued += 1 except Exception as e: failed.append({"recipient": r.get("phone_number"), "error": str(e)}) return {"queued": queued, "failed": len(failed), "errors": failed} ``` Run the broker, worker, and beat scheduler as one stack; credentials come in as environment variables: ```yaml # docker-compose.yml services: redis: image: redis:7-alpine worker: build: . command: celery -A celery_app.app worker --queues=messages,webhooks --loglevel=info environment: - SENT_DM_API_KEY=${SENT_DM_API_KEY} - CELERY_BROKER_URL=redis://redis:6379/0 depends_on: [redis] beat: build: . command: celery -A celery_app.app beat --loglevel=info environment: - CELERY_BROKER_URL=redis://redis:6379/0 depends_on: [redis] ``` The [SDK testing guide](/sdks/testing) covers mocking `messages.send` in task tests. ## Next steps - Build the webhook endpoint in your web framework: [Flask](/sdks/python/integrations/flask), [Django](/sdks/python/integrations/django), or [FastAPI](/sdks/python/integrations/fastapi) - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Explore the [Python SDK reference](/sdks/python) for error types and client options - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/django.txt TITLE: Sending messages from Django with the Sent Python SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/django.txt Wire the Sent Python SDK into a Django project: install, configure the client, send a template message from a view, verify webhooks, and test with sandbox mode. # Sending messages from Django with the Sent Python SDK This guide shows you how to wire Sent messaging into an existing Django project: install the Python SDK, configure a shared client, send a template message from a view, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Django 4.2+ project and familiarity with views and URL configuration. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the `sentdm` package to your existing environment: ```bash pip install sentdm ``` ### Configure the client Read the credentials in `settings.py` so every module gets them from one place; the webhook secret arrives in step 4: ```python # settings.py import os SENT_DM_API_KEY = os.environ.get("SENT_DM_API_KEY") SENT_DM_WEBHOOK_SECRET = os.environ.get("SENT_DM_WEBHOOK_SECRET") if not SENT_DM_API_KEY: raise ValueError("SENT_DM_API_KEY environment variable is required.") ``` Create one cached client for the whole process instead of building a new one per request: ```python # sent_integration/client.py from functools import lru_cache from django.conf import settings from sent_dm import Sent @lru_cache(maxsize=1) def get_sent_client() -> Sent: return Sent(api_key=settings.SENT_DM_API_KEY) ``` ### Send a template message from a view Add a JSON view that calls `messages.send`; the pass-through `sandbox` flag lets callers exercise the view without delivering anything. Protect the route with your existing API authentication (it is left open here for brevity): ```python # sent_integration/views.py import json from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from .client import get_sent_client @csrf_exempt @require_POST def send_message(request): data = json.loads(request.body) response = get_sent_client().messages.send( to=[data["phone_number"]], # E.164 format, for example +14155551234 template={ "name": data["template_name"], # reference by "name" or "id", never both "parameters": data.get("parameters", {}), }, channel=data.get("channels"), # omit to let Sent pick per recipient sandbox=data.get("sandbox", False), # True = validate and simulate only ) recipient = response.data.recipients[0] return JsonResponse( {"message_id": recipient.message_id, "status": response.data.status}, status=202, ) ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`. Delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a webhook view that verifies the `X-Webhook-Signature` header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```python # sent_integration/webhooks.py import base64 import hashlib import hmac import json import logging import time from django.conf import settings from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt logger = logging.getLogger(__name__) @method_decorator(csrf_exempt, name="dispatch") class WebhookView(View): http_method_names = ["post"] def post(self, request, *args, **kwargs): webhook_secret = getattr(settings, "SENT_DM_WEBHOOK_SECRET", None) if not webhook_secret: return JsonResponse({"error": "Webhook secret not configured"}, status=500) signature = request.headers.get("X-Webhook-Signature", "") webhook_id = request.headers.get("X-Webhook-ID", "") timestamp = request.headers.get("X-Webhook-Timestamp", "") if not signature: return JsonResponse({"error": "Missing signature"}, status=401) # Strip the "whsec_" prefix and base64-decode to get the raw HMAC key key_bytes = base64.b64decode(webhook_secret.removeprefix("whsec_")) # Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" signed = f"{webhook_id}.{timestamp}.{request.body.decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode("utf-8"), hashlib.sha256).digest() expected = "v1," + base64.b64encode(digest).decode() if not hmac.compare_digest(signature, expected): return JsonResponse({"error": "Invalid signature"}, status=401) # Reject replayed events older than 5 minutes try: if abs(time.time() - int(timestamp)) > 300: return JsonResponse({"error": "Timestamp too old"}, status=401) except ValueError: return JsonResponse({"error": "Invalid timestamp"}, status=401) event = json.loads(request.body.decode("utf-8")) payload = event.get("payload", {}) # Envelope: {"field": ..., "event": ..., "timestamp": ..., "payload": {...}} if event.get("field") != "message": return JsonResponse({"received": True}) match event.get("event"): case "message.delivered": logger.info("Message %s delivered", payload.get("message_id")) case "message.failed": logger.error("Message %s failed (status %s)", payload.get("message_id"), payload.get("message_status")) case "message.received": logger.info("Inbound %s from %s: %s", payload.get("channel"), payload.get("inbound_number"), payload.get("text")) case _: logger.info("Message %s status: %s", payload.get("message_id"), payload.get("message_status")) return JsonResponse({"received": True}) ``` Route both views in your URL configuration: ```python # urls.py from django.urls import path from sent_integration import views, webhooks urlpatterns = [ path("api/messages/send", views.send_message), path("webhooks/sent/", webhooks.WebhookView.as_view()), ] ``` Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Django integration", "endpoint_url": "https://your-domain.example/webhooks/sent/", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the development server with your credentials loaded: ```bash python manage.py runserver ``` Send a sandbox message through your new view. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:8000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phone_number": "+14155551234", "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your Django log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you track delivery state, persist a record keyed by `message_id` when you send, then update it from the webhook handlers. The appendix below has a model for this. - If you send in bulk or on a schedule, move the `messages.send` call into a background worker; the [Celery integration guide](/sdks/python/integrations/celery) shows the pattern. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and hand off to a task queue so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send free-form text instead of a template, pass `text` instead of `template`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Django stack. Adapt them to your own conventions rather than adopting them wholesale. Persist one row per send and update it from webhook events, keyed by the `message_id` Sent returned: ```python # sent_integration/models.py from django.db import models class SentMessage(models.Model): sent_id = models.CharField(max_length=64, unique=True, null=True) phone_number = models.CharField(max_length=20) template_name = models.CharField(max_length=100) status = models.CharField(max_length=20, default="pending") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) ``` In the webhook handlers, replace the log lines with an update: ```python SentMessage.objects.filter(sent_id=payload.get("message_id")).update( status=(payload.get("message_status") or "").lower() ) ``` If your project exposes its API through DRF, wrap the send call in a serializer-validated `APIView` instead of the plain view: ```python # sent_integration/serializers.py from rest_framework import serializers class SendMessageSerializer(serializers.Serializer): phone_number = serializers.RegexField(r"^\+[1-9]\d{1,14}$") template_name = serializers.CharField(max_length=100) parameters = serializers.DictField(child=serializers.CharField(), required=False) channels = serializers.ListField(child=serializers.ChoiceField(["sms", "whatsapp", "rcs"]), required=False) sandbox = serializers.BooleanField(default=False) ``` Keep webhook views outside DRF authentication. Sent authenticates with the signature, not a session or token. Fail fast at boot when configuration is missing instead of at first send: ```python # sent_integration/apps.py import warnings from django.apps import AppConfig from django.conf import settings class SentIntegrationConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "sent_integration" def ready(self): if not getattr(settings, "SENT_DM_WEBHOOK_SECRET", None): warnings.warn("SENT_DM_WEBHOOK_SECRET is not set. Webhook verification will fail.", RuntimeWarning, stacklevel=2) ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Python SDK reference](/sdks/python) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/fastapi.txt TITLE: Sending messages from FastAPI with the Sent Python SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/fastapi.txt Wire the Sent Python SDK into a FastAPI app: install, configure a client dependency, send messages from a route, verify webhooks, and test with sandbox mode. # Sending messages from FastAPI with the Sent Python SDK This guide shows you how to wire Sent messaging into an existing FastAPI app: install the Python SDK, manage an async client with the app lifespan, send a template message from a route, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working FastAPI app and familiarity with dependencies and Pydantic models. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the `sentdm` package to your existing environment: ```bash pip install sentdm ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Open one `AsyncSent` client at startup and close it at shutdown, so every request shares the same connection pool; `AsyncSent()` reads `SENT_DM_API_KEY` by default: ```python # app/sent_client.py from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI from sent_dm import AsyncSent class SentClientManager: def __init__(self) -> None: self._client: AsyncSent | None = None async def startup(self) -> None: self._client = AsyncSent() await self._client.__aenter__() async def shutdown(self) -> None: if self._client: await self._client.__aexit__(None, None, None) self._client = None @property def client(self) -> AsyncSent: if self._client is None: raise RuntimeError("Sent client not initialized") return self._client sent_manager = SentClientManager() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await sent_manager.startup() yield await sent_manager.shutdown() ``` Attach the lifespan and expose the client as a dependency: ```python # app/main.py (excerpt) from fastapi import FastAPI from sent_dm import AsyncSent from app.sent_client import lifespan, sent_manager app = FastAPI(lifespan=lifespan) def get_sent_client() -> AsyncSent: return sent_manager.client ``` ### Send a template message from a route Add a validated route that calls `messages.send`; the pass-through `sandbox` flag lets callers exercise the route without delivering anything: ```python # app/routers/messages.py from fastapi import APIRouter, Depends, status from pydantic import BaseModel, Field from sent_dm import AsyncSent from app.main import get_sent_client router = APIRouter(prefix="/api/messages", tags=["Messages"]) class SendMessageRequest(BaseModel): phone_number: str = Field(pattern=r"^\+[1-9]\d{1,14}$") # E.164 format template_name: str parameters: dict[str, str] = Field(default_factory=dict) channels: list[str] | None = None # omit to let Sent pick per recipient sandbox: bool = False # True = validate and simulate only @router.post("/send", status_code=status.HTTP_202_ACCEPTED) async def send_message( request: SendMessageRequest, client: AsyncSent = Depends(get_sent_client), ) -> dict[str, str]: response = await client.messages.send( to=[request.phone_number], template={ "name": request.template_name, # reference by "name" or "id", never both "parameters": request.parameters, }, channel=request.channels, sandbox=request.sandbox, ) recipient = response.data.recipients[0] return {"message_id": recipient.message_id, "status": response.data.status} ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`. Delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a dependency that verifies the `X-Webhook-Signature` header against the raw request body before your handler trusts any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. For the full scheme, refer to [webhook signature verification](/start/webhooks/signature-verification): ```python # app/dependencies.py import base64 import hashlib import hmac import os import time from fastapi import Request, Header, HTTPException, status async def verify_webhook_signature( request: Request, x_webhook_signature: str | None = Header(None), x_webhook_id: str = Header(""), x_webhook_timestamp: str = Header(""), ) -> bytes: if not x_webhook_signature: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing X-Webhook-Signature header") webhook_secret = os.environ.get("SENT_DM_WEBHOOK_SECRET") if not webhook_secret: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Webhook secret not configured") payload = await request.body() # Strip the "whsec_" prefix and base64-decode to get the raw HMAC key key_bytes = base64.b64decode(webhook_secret.removeprefix("whsec_")) # Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" signed = f"{x_webhook_id}.{x_webhook_timestamp}.{payload.decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode("utf-8"), hashlib.sha256).digest() expected = "v1," + base64.b64encode(digest).decode() if not hmac.compare_digest(x_webhook_signature, expected): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid webhook signature") # Reject replayed events older than 5 minutes try: if abs(time.time() - int(x_webhook_timestamp)) > 300: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Timestamp outside tolerance") except ValueError: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid timestamp") from None return payload ``` Add the endpoint itself. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; `BackgroundTasks` returns 200 immediately and processes the event after the response: ```python # app/routers/webhooks.py import json import logging from typing import Any from fastapi import APIRouter, BackgroundTasks, Depends, status from app.dependencies import verify_webhook_signature logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) async def process_webhook_event(event: dict[str, Any]) -> None: payload = event.get("payload", {}) if event.get("field") != "message": logger.info("Unhandled webhook field: %s", event.get("field")) return match event.get("event"): case "message.delivered": logger.info("Message %s delivered", payload.get("message_id")) case "message.failed": logger.error("Message %s failed (status %s)", payload.get("message_id"), payload.get("message_status")) case "message.received": logger.info("Inbound %s from %s: %s", payload.get("channel"), payload.get("inbound_number"), payload.get("text")) case _: logger.info("Message %s status: %s", payload.get("message_id"), payload.get("message_status")) @router.post("/sent", status_code=status.HTTP_200_OK) async def handle_webhook( background_tasks: BackgroundTasks, payload: bytes = Depends(verify_webhook_signature), ) -> dict[str, bool]: background_tasks.add_task(process_webhook_event, json.loads(payload)) return {"received": True} ``` Include both routers in your app, then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "FastAPI integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Set `SENT_DM_WEBHOOK_SECRET` to that value. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash uvicorn app.main:app --reload ``` Send a sandbox message through your new route, which runs full validation but delivers nothing and consumes no credits: ```bash curl -X POST http://localhost:8000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phone_number": "+14155551234", "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 422 or 400 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your application log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you already manage settings with `pydantic-settings`, read `SENT_DM_API_KEY` and `SENT_DM_WEBHOOK_SECRET` there and pass them explicitly. The appendix below shows the pattern. - If a webhook event triggers slow work beyond a `BackgroundTasks` call (fan-out, long database writes), hand it to a task queue instead; see [handling webhook retries](/start/webhooks/handling-retries). - To send to many recipients, pass them all in `to`; Sent creates one message per recipient-and-channel pair in a single call. - To send free-form text instead of a template, pass `text` instead of `template`, since each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production FastAPI stack. Adapt them to your own conventions rather than adopting them wholesale. Fail fast at boot when required variables are missing instead of at first send: ```python # app/config.py from functools import lru_cache from pydantic_settings import BaseSettings class Settings(BaseSettings): sent_dm_api_key: str sent_dm_webhook_secret: str = "" rate_limit: str = "10/minute" @lru_cache def get_settings() -> Settings: return Settings() ``` Cap how often callers can hit your send route: ```python # app/dependencies.py (addition) from slowapi import Limiter from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address) # in main.py: app.state.limiter = limiter # on the route: @limiter.limit("10/minute") ``` Collect successes and failures separately when looping over many sends: ```python # app/services/batch.py async def send_batch(client, requests: list[SendMessageRequest]) -> dict: messages, errors = [], [] for req in requests: try: response = await client.messages.send( to=[req.phone_number], template={"name": req.template_name, "parameters": req.parameters}, sandbox=req.sandbox, ) messages.append(response.data.recipients[0].message_id) except Exception as e: errors.append({"recipient": req.phone_number, "error": str(e)}) return {"successful": len(messages), "failed": len(errors), "message_ids": messages, "errors": errors} ``` The [SDK testing guide](/sdks/testing) covers mocking `messages.send` in pytest. ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Python SDK reference](/sdks/python) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/flask.txt TITLE: Sending messages from Flask with the Sent Python SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/flask.txt Wire the Sent Python SDK into a Flask app: install, configure the client, send a template message from a route, verify webhooks, and test with sandbox mode. # Sending messages from Flask with the Sent Python SDK This guide shows you how to wire Sent messaging into an existing Flask app: install the Python SDK, configure a shared client, send a template message from a route, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Flask 3.x app and familiarity with blueprints. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the `sentdm` package to your existing environment: ```bash pip install sentdm ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Create one shared client and cache it on Flask's request context; `Sent()` reads `SENT_DM_API_KEY` by default: ```python # app/sent_client.py from flask import g from sent_dm import Sent def get_sent_client() -> Sent: if "sent_client" not in g: g.sent_client = Sent() return g.sent_client ``` ### Send a template message from a route Add a blueprint route that calls `messages.send`; the pass-through `sandbox` flag lets callers exercise the route without delivering anything: ```python # app/messages.py from flask import Blueprint, jsonify, request from app.sent_client import get_sent_client messages_bp = Blueprint("messages", __name__, url_prefix="/api/messages") @messages_bp.post("/send") def send_message(): data = request.get_json(force=True) response = get_sent_client().messages.send( to=[data["phone_number"]], # E.164 format, for example +14155551234 template={ "name": data["template_name"], # reference by "name" or "id", never both "parameters": data.get("parameters", {}), }, channel=data.get("channels"), # omit to let Sent pick per recipient sandbox=data.get("sandbox", False), # True = validate and simulate only ) recipient = response.data.recipients[0] return jsonify({"message_id": recipient.message_id, "status": response.data.status}), 202 ``` Register the blueprint wherever you create your app: ```python app.register_blueprint(messages_bp) ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`: delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a decorator that verifies the `X-Webhook-Signature` header against the raw request body before your handler trusts any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. See [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```python # app/webhook_auth.py import base64 import functools import hashlib import hmac import os import time from flask import request, jsonify def verify_webhook_signature(f): @functools.wraps(f) def decorated_function(*args, **kwargs): signature = request.headers.get('X-Webhook-Signature') if not signature: return jsonify({'error': 'Missing webhook signature'}), 401 webhook_secret = os.environ.get('SENT_DM_WEBHOOK_SECRET') if not webhook_secret: return jsonify({'error': 'Webhook secret not configured'}), 500 webhook_id = request.headers.get('X-Webhook-ID', '') timestamp = request.headers.get('X-Webhook-Timestamp', '') # Strip the "whsec_" prefix and base64-decode to get the raw HMAC key key_bytes = base64.b64decode(webhook_secret.removeprefix('whsec_')) # Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}" signed = f"{webhook_id}.{timestamp}.{request.get_data().decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest() expected = 'v1,' + base64.b64encode(digest).decode() if not hmac.compare_digest(signature, expected): return jsonify({'error': 'Invalid signature'}), 401 # Reject replayed events older than 5 minutes try: if abs(time.time() - int(timestamp)) > 300: return jsonify({'error': 'Timestamp too old'}), 401 except ValueError: return jsonify({'error': 'Invalid timestamp'}), 401 return f(*args, **kwargs) return decorated_function ``` Add the endpoint itself. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; return 200 quickly and do slow work elsewhere: ```python # app/webhooks.py import logging from flask import Blueprint, jsonify, request from app.webhook_auth import verify_webhook_signature logger = logging.getLogger(__name__) webhooks_bp = Blueprint("webhooks", __name__, url_prefix="/webhooks") @webhooks_bp.post("/sent") @verify_webhook_signature def handle_sent_webhook(): event = request.get_json(force=True) payload = event.get("payload", {}) if event.get("field") != "message": logger.info("Unhandled webhook field: %s", event.get("field")) return jsonify({"received": True}), 200 match event.get("event"): case "message.delivered": logger.info("Message %s delivered", payload.get("message_id")) case "message.failed": logger.error("Message %s failed (status %s)", payload.get("message_id"), payload.get("message_status")) case "message.received": logger.info("Inbound %s from %s: %s", payload.get("channel"), payload.get("inbound_number"), payload.get("text")) case _: logger.info("Message %s status: %s", payload.get("message_id"), payload.get("message_status")) return jsonify({"received": True}), 200 ``` Register `webhooks_bp` the same way as the messages blueprint, then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Flask integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`, which goes in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash flask run ``` Send a sandbox message through your new route (full validation runs, but nothing is delivered and no credits are consumed): ```bash curl -X POST http://localhost:5000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phone_number": "+14155551234", "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 here means the request shape is wrong. Sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your Flask log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you send in bulk or on a schedule, move the `messages.send` call into a background worker; the [Celery integration guide](/sdks/python/integrations/celery) shows the pattern. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and process asynchronously so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send free-form text instead of a template, pass `text` instead of `template`, since each send carries exactly one of the two. - If you need request validation, rate limiting, or security headers around these routes, the appendix below has the scaffolding. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional deployment scaffolding for a Flask stack. Adapt them to your own conventions rather than adopting them wholesale. Load per-environment settings from a config object instead of scattered `os.getenv` calls: ```python # app/config.py import os class Config: SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key') SENT_DM_API_KEY = os.getenv('SENT_DM_API_KEY') SENT_DM_WEBHOOK_SECRET = os.getenv('SENT_DM_WEBHOOK_SECRET') class DevelopmentConfig(Config): DEBUG = True class TestingConfig(Config): TESTING = True SENT_DM_API_KEY = 'test-api-key' # whsec_ + base64("test-webhook-secret") — matches the documented secret format SENT_DM_WEBHOOK_SECRET = 'whsec_dGVzdC13ZWJob29rLXNlY3JldA==' class ProductionConfig(Config): DEBUG = False ``` Wire the blueprints into an application factory if your project uses one: ```python # app/__init__.py from flask import Flask from app.messages import messages_bp from app.webhooks import webhooks_bp def create_app(config_object="app.config.ProductionConfig"): app = Flask(__name__) app.config.from_object(config_object) app.register_blueprint(messages_bp) app.register_blueprint(webhooks_bp) return app ``` Flask-Limiter caps how often callers can hit your send route; Flask-Talisman adds security headers such as `Strict-Transport-Security` and `Content-Security-Policy`: ```python # app/extensions.py from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_talisman import Talisman limiter = Limiter(key_func=get_remote_address, default_limits=["100 per minute"]) talisman = Talisman() # in create_app(): # limiter.init_app(app) # talisman.init_app(app, force_https=False, strict_transport_security=True) # per route: @limiter.limit("10 per minute") ``` Point `RATELIMIT_STORAGE_URI` at Redis (for example `redis://redis:6379/0`) when you run more than one worker. Run Flask behind Gunicorn in production: ```python # gunicorn.conf.py import multiprocessing import os bind = os.getenv('GUNICORN_BIND', '0.0.0.0:8000') workers = multiprocessing.cpu_count() * 2 + 1 timeout = 30 accesslog = '-' errorlog = '-' ``` Containerize with a minimal image; credentials come in as environment variables at run time: ```dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"] ``` This pytest helper signs payloads exactly the way Sent does, so you can test the verification decorator offline; the [SDK testing guide](/sdks/testing) covers mocking `messages.send`: ```python # tests/test_webhooks.py import base64, hashlib, hmac, json, time def signed_headers(payload: str, secret: str) -> dict: webhook_id = '550e8400-e29b-41d4-a716-446655440000' timestamp = str(int(time.time())) key_bytes = base64.b64decode(secret.removeprefix('whsec_')) signed = f"{webhook_id}.{timestamp}.{payload}" digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest() return { 'X-Webhook-Signature': 'v1,' + base64.b64encode(digest).decode(), 'X-Webhook-ID': webhook_id, 'X-Webhook-Timestamp': timestamp, } def test_webhook_valid_signature(client, app): payload = json.dumps({'field': 'message', 'event': 'message.delivered', 'payload': {'message_id': 'msg_123', 'message_status': 'DELIVERED'}}) response = client.post('/webhooks/sent', data=payload, headers=signed_headers(payload, app.config['SENT_DM_WEBHOOK_SECRET'])) assert response.status_code == 200 ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Python SDK reference](/sdks/python) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/ruby.txt TITLE: Ruby SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/ruby.txt Official Ruby SDK for Sent. Elegant DSL for Rails applications with full type support. # Ruby SDK The official Ruby SDK for Sent provides an elegant, Ruby-idiomatic interface for sending messages. Designed for Rails applications, with Yard docstrings and RBS and RBI type signatures. ## Requirements Ruby 3.2.0 or later. ## Installation To use this gem, install via Bundler by adding the following to your app's `Gemfile`: ```ruby gem "sentdm" ``` Then run: ```bash bundle install ``` Or install directly: ```bash gem install sentdm ``` ## Quick Start ### Initialize the client ```ruby require "sentdm" sent_dm = Sentdm::Client.new( api_key: ENV["SENT_DM_API_KEY"] # This is the default and can be omitted ) ``` ### Send your first message ```ruby require "sentdm" sent_dm = Sentdm::Client.new result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: { name: "John Doe", order_id: "12345" } }, channel: ["sms", "whatsapp", "rcs"] ) puts(result.data.recipients[0].message_id) puts(result.data.status) ``` ## Authentication The client reads `SENT_DM_API_KEY` from the environment by default, or you can pass it explicitly: ```ruby require "sentdm" # Using environment variables sent_dm = Sentdm::Client.new # Or explicit configuration sent_dm = Sentdm::Client.new( api_key: "your_api_key" ) ``` ## Send Messages The Ruby SDK uses `send_` (with trailing underscore) instead of `send` because `send` is a reserved method in Ruby's Object class. ### Send a message ```ruby result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: { name: "John Doe", order_id: "12345" } }, channel: ["sms", "whatsapp", "rcs"] ) puts(result.data.recipients[0].message_id) puts(result.data.status) ``` ### Sandbox mode Use `sandbox: true` to validate requests without sending real messages: ```ruby result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, sandbox: true # Validates but doesn't send ) # Response will have test data puts(result.data.recipients[0].message_id) puts(result.data.status) ``` ## Check message status Retrieve the current status of a sent message. The `direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```ruby status = sent_dm.messages.retrieve_status("msg-uuid") puts status.data.status # e.g. "DELIVERED" puts status.data.channel # e.g. "sms" puts status.data.direction # "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```ruby activities = sent_dm.messages.retrieve_activities("msg-uuid") activities.data.activities.each do |activity| puts "#{activity.timestamp}: #{activity.status} via #{activity.from}" puts " Price: #{activity.price}" puts " Active contact price: #{activity.active_contact_price}" end ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```ruby result = sent_dm.numbers.lookup("+12025551234") puts result.data.is_valid # true/false puts result.data.carrier_name # e.g. "T-Mobile" puts result.data.line_type # "mobile", "landline", "voip" puts result.data.is_voip # true/false ``` ## Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (that is, 4xx or 5xx response), a subclass of `Sentdm::Errors::APIError` will be thrown: ```ruby begin result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: {name: "John Doe", order_id: "12345"} } ) rescue Sentdm::Errors::APIConnectionError => e puts("The server could not be reached") puts(e.cause) # an underlying Exception, likely raised within `net/http` rescue Sentdm::Errors::RateLimitError => e puts("A 429 status code was received; we should back off a bit.") rescue Sentdm::Errors::APIStatusError => e puts("Another non-200-range status code was received") puts(e.status) end ``` Error codes are as follows: | Cause | Error Type | |-------|------------| | HTTP 400 | `BadRequestError` | | HTTP 401 | `AuthenticationError` | | HTTP 403 | `PermissionDeniedError` | | HTTP 404 | `NotFoundError` | | HTTP 409 | `ConflictError` | | HTTP 422 | `UnprocessableEntityError` | | HTTP 429 | `RateLimitError` | | HTTP >= 500 | `InternalServerError` | | Other HTTP error | `APIStatusError` | | Timeout | `APITimeoutError` | | Network error | `APIConnectionError` | ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. ```ruby # Configure the default for all requests: sent_dm = Sentdm::Client.new( max_retries: 0 # default is 2 ) # Or, configure per-request: sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, request_options: {max_retries: 5} ) ``` ## Timeouts Requests time out after 60 seconds by default. Set `timeout` (in seconds) on the client or per request; `timeout: nil` disables the timeout entirely. ```ruby # Configure the default for all requests: sent_dm = Sentdm::Client.new( timeout: 20 # seconds (default is 60; nil disables the timeout) ) # Or, configure per-request: sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, request_options: {timeout: 5} ) ``` ## BaseModel All parameter and response objects inherit from `Sentdm::Internal::Type::BaseModel`, which provides several conveniences: 1. All fields, including unknown ones, are accessible with `obj[:prop]` syntax 2. Structural equivalence for equality 3. Both instances and classes can be pretty-printed 4. Helpers such as `#to_h`, `#deep_to_h`, `#to_json`, and `#to_yaml` ```ruby result = sent_dm.templates.list(page: 1, page_size: 100) # Access fields template = result.data.templates.first template.name template[:name] # Same thing # Convert to hash template.to_h # Serialize to JSON template.to_json # Pretty print puts template.inspect ``` ## Contacts Create and manage contacts: ```ruby # Create a contact result = sent_dm.contacts.create( phone_number: "+1234567890" ) puts "Contact ID: #{result.data.id}" puts "Channels: #{result.data.available_channels}" # List contacts result = sent_dm.contacts.list(page: 1, page_size: 100) result.data.contacts.each do |contact| puts "#{contact.phone_number} - #{contact.available_channels}" end # Get a contact result = sent_dm.contacts.retrieve("contact-uuid") # Update a contact result = sent_dm.contacts.update( "contact-uuid", default_channel: "whatsapp" ) # Delete a contact sent_dm.contacts.delete("contact-uuid") ``` ## Templates List and retrieve templates: ```ruby # List templates result = sent_dm.templates.list(page: 1, page_size: 100) result.data.templates.each do |template| puts "#{template.name} (#{template.status}): #{template.id}" puts " Category: #{template.category}" puts " Channels: #{template.channels.join(', ')}" end # Get a specific template result = sent_dm.templates.retrieve("template-uuid") puts "Name: #{result.data.name}" puts "Status: #{result.data.status}" ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. Complete verified webhook handlers are available in the [Rails integration guide](/sdks/ruby/integrations/rails) and the [Sinatra integration guide](/sdks/ruby/integrations/sinatra); the [Webhooks reference](/start/webhooks) documents the full payload schema and all status values. ## Making custom or undocumented requests ### Undocumented properties You can send undocumented parameters to any endpoint using `extra_*` options: ```ruby result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, request_options: { extra_query: {my_query_parameter: value}, extra_body: {my_body_parameter: value}, extra_headers: {"my-header" => value} } ) puts(result[:my_undocumented_property]) ``` ### Undocumented endpoints To make requests to undocumented endpoints: ```ruby response = sent_dm.request( method: :post, path: '/undocumented/endpoint', query: {"dog" => "woof"}, headers: {"useful-header" => "interesting-value"}, body: {"hello" => "world"} ) ``` ## Concurrency & connection pooling The `Sentdm::Client` instances are thread-safe, but are only fork-safe when there are no in-flight HTTP requests. Each instance of `Sentdm::Client` has its own HTTP connection pool with a default size of 99 (or the number of CPU cores, if greater). As such, Sent recommends instantiating the client once per app in most settings. When all available connections from the pool are checked out, requests wait for a new connection to become available, with queue time counting towards the request timeout. ## Framework Integration Dedicated guides cover client setup, message sending, verified webhook handling, and testing for each framework: ## Sorbet This library provides [RBI](https://sorbet.org/start/rbi) definitions and has no dependency on sorbet-runtime. You can provide typesafe request parameters: ```ruby sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: {name: "John Doe"} } ) ``` ## Source & Issues - **Releases**: [GitHub Releases](https://github.com/sentdm/sent-dm-ruby/releases) - **GitHub**: [`sentdm/sent-dm-ruby`](https://github.com/sentdm/sent-dm-ruby) - **RubyGems**: [`sentdm`](https://rubygems.org/gems/sentdm) - **RubyDoc**: [`gemdocs.org/gems/sentdm`](https://gemdocs.org/gems/sentdm) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-ruby/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/ruby/integrations/rails.txt TITLE: Sending messages from Rails with the Sent Ruby SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/ruby/integrations/rails.txt Wire the Sent Ruby SDK into a Rails app: install, configure the client, send a template message from a controller, verify webhooks, and test with sandbox mode. # Sending messages from Rails with the Sent Ruby SDK This guide shows you how to wire Sent messaging into an existing Rails app: install the Ruby SDK, configure a shared client, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Rails 7.1+ app and familiarity with controllers, concerns, and initializers. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the gem to your `Gemfile` and install: ```ruby gem "sentdm", "~> 0.25.0" ``` ```bash bundle install ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Create an initializer that validates configuration at boot and memoizes one client for the whole process: ```ruby # config/initializers/sentdm.rb module SentDmConfig class ConfigurationError < StandardError; end class << self def configure raise ConfigurationError, 'SENT_DM_API_KEY required' if ENV['SENT_DM_API_KEY'].blank? client # eagerly initialize and memoize end def client @client ||= Sentdm::Client.new(api_key: ENV.fetch('SENT_DM_API_KEY')) end end end SentDmConfig.configure ``` ### Send a template message from a controller Add a controller that calls `messages.send_`; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything. Protect the route with your existing API authentication: ```ruby # app/controllers/messages_controller.rb class MessagesController < ApplicationController skip_before_action :verify_authenticity_token # JSON API endpoint; use your own auth def create response = SentDmConfig.client.messages.send_( to: [params.require(:phone_number)], # E.164 format, for example +14155551234 template: { name: params.require(:template_name), # reference by name or id, never both parameters: params.fetch(:parameters, {}).permit!.to_h }, channel: params[:channels], # omit to let Sent pick per recipient sandbox: ActiveModel::Type::Boolean.new.cast(params[:sandbox]) # true = simulate only ) recipient = response.data.recipients[0] render json: { message_id: recipient.message_id, status: response.data.status }, status: :accepted rescue Sentdm::Errors::UnprocessableEntityError, Sentdm::Errors::BadRequestError => e render json: { error: e.message }, status: :unprocessable_entity end end ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`: delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a concern that verifies the `X-Webhook-Signature` header against the raw request body before any handler runs. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```ruby # app/controllers/concerns/webhook_verifiable.rb module WebhookVerifiable extend ActiveSupport::Concern class SignatureVerificationError < StandardError; end TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed events included do skip_before_action :verify_authenticity_token, only: [:create] before_action :verify_webhook_signature, only: [:create] rescue_from SignatureVerificationError, with: :handle_invalid_signature end private def verify_webhook_signature webhook_id = request.headers['X-Webhook-ID'] timestamp = request.headers['X-Webhook-Timestamp'] signature = request.headers['X-Webhook-Signature'] payload = request.body.read; request.body.rewind raise SignatureVerificationError, 'Missing signature headers' if [webhook_id, timestamp, signature].any?(&:blank?) raise SignatureVerificationError, 'Timestamp outside tolerance' if (Time.now.to_i - timestamp.to_i).abs > TIMESTAMP_TOLERANCE raise SignatureVerificationError, 'Invalid signature' unless secure_compare(expected_signature(webhook_id, timestamp, payload), signature) end def expected_signature(webhook_id, timestamp, payload) secret = ENV.fetch('SENT_DM_WEBHOOK_SECRET') # "whsec_..." key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_')) digest = OpenSSL::HMAC.digest('SHA256', key_bytes, "#{webhook_id}.#{timestamp}.#{payload}") "v1,#{Base64.strict_encode64(digest)}" end def secure_compare(a, b) = ActiveSupport::SecurityUtils.secure_compare(a.to_s, b.to_s) def handle_invalid_signature = render json: { error: 'Unauthorized' }, status: :unauthorized end ``` Add the controller. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; return 200 quickly and do slow work in a job: ```ruby # app/controllers/webhooks_controller.rb class WebhooksController < ApplicationController include WebhookVerifiable def create payload = request.body.read; request.body.rewind event = JSON.parse(payload) data = event['payload'] || {} if event['field'] == 'message' case event['event'] # sub-type; omitted for template events when 'message.delivered' Rails.logger.info "Message #{data['message_id']} delivered" when 'message.failed' Rails.logger.error "Message #{data['message_id']} failed (status #{data['message_status']})" when 'message.received' Rails.logger.info "Inbound #{data['channel']} from #{data['inbound_number']}: #{data['text']}" else Rails.logger.info "Message #{data['message_id']} status: #{data['message_status']}" end end render json: { received: true } rescue JSON::ParserError render json: { error: 'Invalid JSON' }, status: :bad_request end end ``` Route both controllers: ```ruby # config/routes.rb post "/api/messages/send", to: "messages#create" post "/webhooks/sent", to: "webhooks#create" ``` Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Rails integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret` (put it in `SENT_DM_WEBHOOK_SECRET`). The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash bin/rails server ``` Send a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:3000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phone_number": "+14155551234", "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 422 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your Rails log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you track delivery state, persist a `Message` record keyed by the returned `message_id` and update it from webhook events. The appendix below has the model and handler. - If you send in bulk or on user lifecycle events, move the `send_` call into an ActiveJob. The appendix has the job. - If a webhook references a `message_id` you do not recognize, acknowledge it with 200 anyway; failing causes Sent to retry the delivery. - To send free-form text instead of a template, pass `text` instead of `template`. Each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Rails stack. Adapt them to your own conventions rather than adopting them wholesale. Persist one row per send and update it from webhook events; the API reports statuses in uppercase (for example `QUEUED`), so downcase before matching your enum: ```ruby # app/models/message.rb class Message < ApplicationRecord enum :status, { pending: 'pending', queued: 'queued', sent: 'sent', delivered: 'delivered', read: 'read', failed: 'failed' } end ``` ```ruby # in the webhook controller, replace the log lines: message = Message.find_by(external_id: data['message_id']) message&.update!(status: data['message_status'].to_s.downcase) ``` Move sends off the request path with retries on transient API errors: ```ruby # app/jobs/send_message_job.rb class SendMessageJob < ApplicationJob queue_as :messages retry_on Sentdm::Errors::RateLimitError, wait: :polynomially_longer, attempts: 5 discard_on Sentdm::Errors::UnprocessableEntityError # invalid requests never succeed def perform(phone_number:, template_name:, parameters: {}) SentDmConfig.client.messages.send_( to: [phone_number], template: { name: template_name, parameters: parameters } ) end end ``` Sign test payloads exactly the way Sent does, so request specs exercise the verification path; the [SDK testing guide](/sdks/testing) covers mocking the client: ```ruby # spec/support/webhook_signature_helper.rb module WebhookSignatureHelper def signed_webhook_headers(payload, secret: ENV.fetch('SENT_DM_WEBHOOK_SECRET')) webhook_id = SecureRandom.uuid timestamp = Time.now.to_i.to_s key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_')) digest = OpenSSL::HMAC.digest('SHA256', key_bytes, "#{webhook_id}.#{timestamp}.#{payload}") { 'X-Webhook-ID' => webhook_id, 'X-Webhook-Timestamp' => timestamp, 'X-Webhook-Signature' => "v1,#{Base64.strict_encode64(digest)}", 'Content-Type' => 'application/json' } end end ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Ruby SDK reference](/sdks/ruby) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/ruby/integrations/sinatra.txt TITLE: Sending messages from Sinatra with the Sent Ruby SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/ruby/integrations/sinatra.txt Wire the Sent Ruby SDK into a Sinatra app: install, configure the client, send a template message from a route, verify webhooks, and test with sandbox mode. # Sending messages from Sinatra with the Sent Ruby SDK This guide shows you how to wire Sent messaging into an existing Sinatra app: install the Ruby SDK, configure a shared client, send a template message from a route, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Sinatra app (classic or modular) and familiarity with routes and helpers. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the gem to your `Gemfile` and install: ```ruby gem "sentdm", "~> 0.25.0" ``` ```bash bundle install ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Memoize one client for the whole process: ```ruby # lib/sent_client.rb require 'sentdm' module SentClient def self.client @client ||= Sentdm::Client.new(api_key: ENV.fetch('SENT_DM_API_KEY')) end end ``` ### Send a template message from a route Add a route that calls `messages.send_`; the pass-through `sandbox` flag lets callers exercise the route without delivering anything: ```ruby # app.rb require 'sinatra/base' require 'json' require_relative 'lib/sent_client' class App < Sinatra::Base post '/api/messages/send' do content_type :json data = JSON.parse(request.body.read, symbolize_names: true) response = SentClient.client.messages.send_( to: [data.fetch(:phone_number)], # E.164 format, for example +14155551234 template: { name: data.fetch(:template_name), # reference by name or id, never both parameters: data.fetch(:parameters, {}) }, channel: data[:channels], # omit to let Sent pick per recipient sandbox: data.fetch(:sandbox, false) # true = validate and simulate only ) recipient = response.data.recipients[0] status 202 { message_id: recipient.message_id, status: response.data.status }.to_json rescue Sentdm::Errors::BadRequestError, Sentdm::Errors::UnprocessableEntityError => e halt 422, { error: e.message }.to_json rescue JSON::ParserError, KeyError halt 400, { error: 'phone_number and template_name are required' }.to_json end end ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`: delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a helper that verifies the `X-Webhook-Signature` header against the raw request body before your route trusts any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```ruby # lib/webhook_helpers.rb require 'openssl' require 'base64' module WebhookHelpers TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed events # Signing secret is "whsec_{base64Key}"; signed content is "{webhookId}.{timestamp}.{rawBody}"; # signature header is "v1,{base64(hmac)}". def verify_webhook_signature!(payload, webhook_id, timestamp, signature) secret = ENV['SENT_DM_WEBHOOK_SECRET'] # Fail closed: never accept webhooks without a configured secret halt 500, { error: 'Webhook not configured' }.to_json if secret.to_s.empty? key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_')) digest = OpenSSL::HMAC.digest('SHA256', key_bytes, "#{webhook_id}.#{timestamp}.#{payload}") expected = "v1,#{Base64.strict_encode64(digest)}" halt 401, { error: 'Invalid webhook signature' }.to_json unless Rack::Utils.secure_compare(expected, signature.to_s) halt 401, { error: 'Webhook timestamp too old' }.to_json if (Time.now.to_i - timestamp.to_i).abs > TIMESTAMP_TOLERANCE end end ``` Add the route. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; return 200 quickly and do slow work elsewhere: ```ruby # app.rb (additions) require_relative 'lib/webhook_helpers' class App < Sinatra::Base helpers WebhookHelpers configure { enable :logging } post '/webhooks/sent' do content_type :json payload = request.body.read request.body.rewind verify_webhook_signature!(payload, env['HTTP_X_WEBHOOK_ID'] || '', env['HTTP_X_WEBHOOK_TIMESTAMP'] || '', env['HTTP_X_WEBHOOK_SIGNATURE'] || '') event = JSON.parse(payload) data = event['payload'] || {} if event['field'] == 'message' case event['event'] # sub-type; omitted for template events when 'message.delivered' logger.info "Message #{data['message_id']} delivered" when 'message.failed' logger.error "Message #{data['message_id']} failed (status #{data['message_status']})" when 'message.received' logger.info "Inbound #{data['channel']} from #{data['inbound_number']}: #{data['text']}" else logger.info "Message #{data['message_id']} status: #{data['message_status']}" end end { received: true }.to_json rescue JSON::ParserError halt 400, { error: 'Invalid JSON' }.to_json end end ``` Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Sinatra integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret` (put it in `SENT_DM_WEBHOOK_SECRET`). The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash bundle exec rackup ``` Send a sandbox message through your new route. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:9292/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phone_number": "+14155551234", "template_name": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `message_id` and `"status": "QUEUED"`. A 400 or 422 here means the request shape is wrong: sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your application log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you track delivery state, persist a record keyed by `message_id` when you send, then update it from the webhook route. The appendix below has a Sequel model sketch. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and hand off to a job queue such as Sidekiq so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - If you run the app behind Puma with multiple workers, the memoized client is created once per worker process, which is the intended pattern. - To send free-form text instead of a template, pass `text` instead of `template`. Each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Sinatra stack. Adapt them to your own conventions rather than adopting them wholesale. Persist one row per send and update it from webhook events: ```ruby # db/migrations/001_create_messages.rb Sequel.migration do change do create_table(:messages) do primary_key :id String :sent_id, index: true # message_id returned by Sent String :phone_number, null: false String :template_name String :status, default: 'pending' Time :created_at Time :updated_at end end end ``` In the webhook route, replace the log lines with an update keyed on `data['message_id']`. Serve the app with Puma in production: ```ruby # config.ru require_relative 'app' run App ``` ```ruby # puma.rb workers Integer(ENV.fetch('WEB_CONCURRENCY', 2)) threads_count = Integer(ENV.fetch('MAX_THREADS', 5)) threads threads_count, threads_count port ENV.fetch('PORT', 9292) environment ENV.fetch('RACK_ENV', 'production') ``` Sign test payloads exactly the way Sent does, so specs exercise the verification path; the [SDK testing guide](/sdks/testing) covers mocking the client: ```ruby # spec/support/webhook_signature_helper.rb def signed_webhook_headers(payload, secret: ENV.fetch('SENT_DM_WEBHOOK_SECRET')) webhook_id = SecureRandom.uuid timestamp = Time.now.to_i.to_s key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_')) digest = OpenSSL::HMAC.digest('SHA256', key_bytes, "#{webhook_id}.#{timestamp}.#{payload}") { 'HTTP_X_WEBHOOK_ID' => webhook_id, 'HTTP_X_WEBHOOK_TIMESTAMP' => timestamp, 'HTTP_X_WEBHOOK_SIGNATURE' => "v1,#{Base64.strict_encode64(digest)}", 'CONTENT_TYPE' => 'application/json' } end ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [Ruby SDK reference](/sdks/ruby) for retries, timeouts, and error types - Read the [SDK best practices guide](/sdks/best-practices) for production deployments ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/testing.txt TITLE: Testing with SDKs ================================================================================ URL: https://docs.sent.dm/llms/sdks/testing.txt Testing strategies for Sent SDKs. Unit tests, integration tests, mocking, and CI/CD best practices. # Testing with SDKs Build reliable messaging features with testing strategies for Sent SDKs. This guide covers unit testing, integration testing, mocking, and CI/CD best practices. ## Testing Pyramid For messaging integrations, follow this testing strategy: 1. **Unit Tests (70%)** - Mock the SDK, test business logic 2. **Integration Tests (20%)** - Use a dedicated API key for your test environment, with sandbox mode to avoid side effects 3. **E2E Tests (10%)** - Full flow with webhook handling ## Unit Testing ### Mock the SDK Don't make real API calls in unit tests: ```typescript // __mocks__/@sentdm/sentdm.ts import { jest } from '@jest/globals'; export const mockSend = jest.fn(); export default jest.fn().mockImplementation(() => ({ messages: { send: mockSend } })); // notification.service.test.ts import { mockSend } from './__mocks__/@sentdm/sentdm'; import { NotificationService } from './notification.service'; describe('NotificationService', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should send welcome message on user signup', async () => { // Arrange mockSend.mockResolvedValue({ data: { recipients: [{ message_id: 'msg_123' }], status: 'QUEUED', price: 0.0125 } }); const service = new NotificationService(); const user = { phone: '+1234567890', name: 'John' }; // Act await service.sendWelcomeMessage(user); // Assert expect(mockSend).toHaveBeenCalledWith({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome', parameters: { name: 'John' } } }); }); it('should handle send failures gracefully', async () => { // Arrange const error = new Error('Template not found'); error.status = 400; mockSend.mockRejectedValue(error); const service = new NotificationService(); const user = { phone: '+1234567890', name: 'John' }; // Act & Assert await expect(service.sendWelcomeMessage(user)) .rejects.toThrow('Failed to send message'); }); }); ``` ```python # conftest.py import pytest from unittest.mock import Mock @pytest.fixture def mock_sent(): mock = Mock() mock.messages.send.return_value = Mock( data=Mock( recipients=[Mock(message_id='msg_123')], status='QUEUED', price=0.0125 ) ) return mock # test_notification_service.py import pytest from unittest.mock import patch def test_send_welcome_message(mock_sent): # Arrange with patch('myapp.services.SentDm', return_value=mock_sent): from myapp.services import NotificationService service = NotificationService() user = {'phone': '+1234567890', 'name': 'John'} # Act service.send_welcome_message(user) # Assert mock_sent.messages.send.assert_called_once_with( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome', 'parameters': {'name': 'John'} } ) def test_handle_send_failure(mock_sent): # Arrange from sent_dm import BadRequestError mock_sent.messages.send.side_effect = BadRequestError( message='Template not found', response=Mock(status_code=400), body=None ) with patch('myapp.services.SentDm', return_value=mock_sent): from myapp.services import NotificationService service = NotificationService() user = {'phone': '+1234567890', 'name': 'John'} # Act & Assert with pytest.raises(Exception) as exc_info: service.send_welcome_message(user) assert 'Template not found' in str(exc_info.value) ``` ```go // mocks/sent_client.go package mocks import ( "context" "github.com/sentdm/sent-dm-go" ) type MockMessagesService struct { SendFunc func(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) } func (m *MockMessagesService) Send(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) { return m.SendFunc(ctx, params) } // notification_service_test.go func TestSendWelcomeMessage(t *testing.T) { // Arrange mockMessages := &mocks.MockMessagesService{ SendFunc: func(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) { // Verify parameters if len(params.To) != 1 || params.To[0] != "+1234567890" { t.Errorf("Expected phone +1234567890, got %v", params.To) } return &sentdm.MessageSendResponse{ Data: sentdm.MessageSendResponseData{ Recipients: []sentdm.MessageSendResponseDataRecipient{ {MessageID: "msg_123"}, }, Status: "QUEUED", }, }, nil }, } service := NewNotificationService(mockMessages) user := User{Phone: "+1234567890", Name: "John"} // Act err := service.SendWelcomeMessage(context.Background(), user) // Assert assert.NoError(t, err) } ``` ### Testing Error Scenarios Test all error paths: ```typescript const errorScenarios = [ { name: 'AuthenticationError', status: 401, message: 'Should throw on invalid API key', shouldRetry: false }, { name: 'RateLimitError', status: 429, message: 'Should retry on rate limit', shouldRetry: true }, { name: 'BadRequestError', status: 400, message: 'Should not retry on 4xx', shouldRetry: false } ]; errorScenarios.forEach(({ name, status, message, shouldRetry }) => { it(message, async () => { const error = new Error('Test error'); (error as any).status = status; mockSend.mockRejectedValue(error); const result = await service.sendMessage({...}); expect(result.retryAttempted).toBe(shouldRetry); }); }); ``` ## Integration Testing ### Use a Dedicated API Key per Environment Use a separate API key for your test/CI environment. There is only one key type, but keeping keys isolated per environment lets you revoke or rotate them independently: ```typescript // integration/message.test.ts import SentDm from '@sentdm/sentdm'; describe('Message Integration Tests', () => { let client: SentDm; beforeAll(() => { // Use test API key client = new SentDm({ apiKey: process.env.SENT_DM_API_KEY_TEST! }); }); it('should validate message request structure with sandbox mode', async () => { // Use sandbox mode to validate without sending real messages const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, sandbox: true // Validates but doesn't send }); // Response will have test data expect(response.data.recipients[0].message_id).toBeDefined(); expect(response.data.status).toBeDefined(); }); it('should handle invalid template', async () => { try { await client.messages.send({ to: ['+1234567890'], template: { id: 'non-existent-template', name: 'invalid' } }); fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(SentDm.BadRequestError); } }); }); ``` ```python # integration/test_messages.py import os import pytest from sent_dm import Sent, BadRequestError @pytest.fixture def test_client(): return Sent(api_key=os.environ['SENT_DM_API_KEY_TEST']) def test_validate_message_structure_with_sandbox_mode(test_client): """Test that valid message structure is accepted using sandbox mode""" # Use sandbox mode to validate without sending real messages response = test_client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" }, sandbox=True # Validates but doesn't send ) # Response will have test data assert response.data.recipients[0].message_id is not None assert response.data.status is not None def test_invalid_template_error(test_client): """Test that invalid template throws error""" with pytest.raises(BadRequestError) as exc_info: test_client.messages.send( to=["+1234567890"], template={ "id": "non-existent-template", "name": "invalid" } ) assert exc_info.value is not None ``` ```go // integration/message_test.go func TestSendMessageIntegrationWithSandbox(t *testing.T) { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY_TEST")), ) ctx := context.Background() // Use Sandbox to validate without sending real messages response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, Sandbox: sentdm.Bool(true), // Validates but doesn't send }) // Should succeed with test data require.NoError(t, err) assert.NotEmpty(t, response.Data.Recipients[0].MessageID) assert.NotEmpty(t, response.Data.Status) } func TestInvalidTemplateIntegration(t *testing.T) { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY_TEST")), ) ctx := context.Background() _, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("non-existent-template"), Name: sentdm.String("invalid"), }, }) require.Error(t, err) var apiErr *sentdm.Error require.True(t, errors.As(err, &apiErr)) assert.Equal(t, 400, apiErr.StatusCode) } ``` ### Test Webhooks Locally Use tools like ngrok or localtunnel for webhook testing. Refer to the [local development guide](/start/webhooks/local-development) for tunnel setup. To assert on your handler, replay a canonical event envelope with the three signature headers your handler verifies, as described in [webhook signature verification](/start/webhooks/signature-verification): ```typescript // webhook.test.ts describe('Webhook Integration', () => { let server: Server; let webhookUrl: string; beforeAll(async () => { // Start local server server = app.listen(3001); // Create tunnel (using ngrok or similar) webhookUrl = await createTunnel(3001); }); afterAll(async () => { server.close(); await closeTunnel(); }); it('should verify webhook signature', async () => { const payload = JSON.stringify({ field: 'message', event: 'message.delivered', timestamp: '2026-07-25T10:10:42Z', payload: { message_id: 'msg_123', message_status: 'DELIVERED', channel: 'sms' } }); const webhookId = 'wh_test_123'; const timestamp = String(Math.floor(Date.now() / 1000)); // Signed content = "{webhookId}.{timestamp}.{rawBody}" const signature = generateTestSignature(webhookId, timestamp, payload); const response = await request(app) .post('/webhooks/sent') .set('X-Webhook-ID', webhookId) .set('X-Webhook-Timestamp', timestamp) .set('X-Webhook-Signature', signature) .set('Content-Type', 'application/json') .send(payload); expect(response.status).toBe(200); }); }); ``` ## CI/CD Testing ### Environment Setup Configure separate environments for CI/CD: ```yaml # .github/workflows/test.yml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm ci - name: Run unit tests run: npm test - name: Run integration tests env: SENT_DM_API_KEY_TEST: ${{ secrets.SENT_DM_API_KEY_TEST }} run: npm run test:integration ``` ### Test Data Management Use consistent test data: ```typescript // test/fixtures.ts export const testFixtures = { validPhoneNumber: '+15555551234', // test number invalidPhoneNumber: 'invalid', validTemplateId: 'test-welcome-template', invalidTemplateId: 'non-existent-template', mockRecipient: { message_id: 'msg_test_123', to: '+15555551234', channel: 'sms' } }; // Use in tests import { testFixtures } from './fixtures'; it('should send to valid number', async () => { mockSend.mockResolvedValue({ data: { status: 'QUEUED', recipients: [testFixtures.mockRecipient] } }); const result = await service.sendMessage({ phone: testFixtures.validPhoneNumber }); expect(mockSend).toHaveBeenCalledWith({ to: [testFixtures.validPhoneNumber], template: { id: expect.any(String), name: expect.any(String) } }); }); ``` ### Parallel Test Execution Ensure tests can run in parallel: ```typescript // Use unique identifiers per test it('should send message', async () => { const testId = `test-${Date.now()}-${Math.random()}`; const phoneNumber = `+1555${testId.slice(-7)}`; mockSend.mockResolvedValue({ data: { recipients: [{ message_id: `msg_${testId}` }], status: 'QUEUED' } }); const result = await service.sendMessage({ phone: phoneNumber }); expect(result.data.recipients[0].message_id).toBe(`msg_${testId}`); }); ``` ## Load Testing Test SDK behavior under load: Keep `sandbox: true` in this script. Without it, every run sends (and bills) 100 real messages. ```typescript // load-test.ts import SentDm from '@sentdm/sentdm'; async function loadTest() { const client = new SentDm({ apiKey: process.env.SENT_DM_API_KEY_TEST! }); const concurrency = 10; const totalRequests = 100; console.log(`Starting load test: ${totalRequests} requests at ${concurrency} concurrency`); const startTime = Date.now(); let successCount = 0; let errorCount = 0; for (let batch = 0; batch < totalRequests / concurrency; batch++) { const promises = Array(concurrency).fill(null).map((_, i) => client.messages.send({ to: [`+1555555${String(batch * concurrency + i).padStart(4, '0')}`], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, sandbox: true // Validates but doesn't send }).then(() => { successCount++; }).catch(err => { errorCount++; console.log('Error:', err.message); }) ); await Promise.all(promises); } const duration = Date.now() - startTime; console.log(`\nResults:`); console.log(`Duration: ${duration}ms`); console.log(`Success: ${successCount}`); console.log(`Errors: ${errorCount}`); console.log(`RPS: ${(totalRequests / (duration / 1000)).toFixed(2)}`); } loadTest(); ``` ## Test Coverage Checklist Use this checklist to ensure comprehensive test coverage for your messaging integration. ### Core Functionality - [ ] Send message with template - [ ] Send message with variables - [ ] Create contact - [ ] Handle successful response - [ ] Handle error response ### Error Scenarios - [ ] Invalid API key - [ ] Rate limiting - [ ] Invalid template ID - [ ] Template not approved - [ ] Insufficient credits - [ ] Invalid phone number - [ ] Contact opted out ### Webhooks - [ ] Verify webhook signature - [ ] Handle message.delivered - [ ] Handle message.failed - [ ] Handle message.received (inbound replies) - [ ] Handle duplicate events (idempotency) - [ ] Handle invalid signatures ### Retry Logic - [ ] Retry on rate limit - [ ] Don't retry on 4xx errors - [ ] Exponential backoff - [ ] Max retry attempts ### Edge Cases - [ ] Empty variables object - [ ] Special characters in message - [ ] Very long messages - [ ] Unicode/emojis - [ ] Concurrent requests ## Next Steps - Apply [SDK best practices](/sdks/best-practices) for production error handling, retries, and webhook security - Resolve common integration errors with the [troubleshooting guide](/sdks/troubleshooting) - Set up [webhooks](/start/webhooks/getting-started) to assert on delivery status in end-to-end tests - Review the [language-specific SDK guides](/sdks) for idiomatic examples in your stack --- Comprehensive testing ensures your messaging integration works reliably in production. Aim for 80%+ code coverage with a good mix of unit and integration tests. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/troubleshooting.txt TITLE: SDK Troubleshooting ================================================================================ URL: https://docs.sent.dm/llms/sdks/troubleshooting.txt Common issues and solutions when using Sent SDKs. Error codes, debugging tips, and FAQs. # SDK Troubleshooting Resolve common issues with Sent SDKs. This guide covers frequent errors, debugging strategies, and solutions organized by symptom. ## Quick Diagnostics Before diving into specific issues, check these common causes: ## Common Error Codes ### Authentication Errors #### `401 Unauthorized` - Invalid API key **Symptoms:** ``` AuthenticationError: 401 - Invalid API key ``` **Solutions:** 1. Verify your API key from the [Sent Dashboard](https://app.sent.dm/dashboard/api-keys) 2. Check for extra whitespace or copy-paste errors 3. Ensure you're using the right environment variable (`SENT_DM_API_KEY`) 4. Verify the key hasn't been revoked ```typescript // Debug: Log first 8 characters console.log('API Key:', process.env.SENT_DM_API_KEY?.substring(0, 8) + '...'); ``` --- ### Contact Errors #### `404 Not Found` - Contact not found **Symptoms:** ``` NotFoundError: 404 - Contact not found ``` **Solutions:** ```typescript // Create contact first try { const contact = await client.contacts.create({ phone_number: '+1234567890' }); console.log('Created contact:', contact.data.id); } catch (error) { console.error('Failed to create contact:', error.message); } ``` ```python # Create contact first try: contact = client.contacts.create( phone_number='+1234567890' ) print(f'Created contact: {contact.data.id}') except Exception as e: print(f'Failed to create contact: {e}') ``` ```go // Create contact first response, err := client.Contacts.New(ctx, sentdm.ContactNewParams{ PhoneNumber: "+1234567890", }) if err != nil { log.Printf("Failed to create contact: %v", err) } else { log.Printf("Created contact: %s", response.Data.ID) } ``` You can send messages directly to a phone number without creating a contact first: ```typescript // Send directly to phone number const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); ``` ```python # Send directly to phone number response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) ``` ```go // Send directly to phone number response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, }) ``` #### Contacts opted out Opt-out blocks are asynchronous. `POST /v3/messages` is accepted with `202` even when every recipient has opted out, so the send call itself never raises an error. Each blocked recipient's message is finalized as `FILTERED` inside the pipeline, before any provider call, and you are not charged for it. Detect the block from the message status, not from the send response: - The `message.filtered` webhook event - `GET /v3/messages/{id}`: `status` is `FILTERED` - `GET /v3/messages/{id}/activities`: a `FILTERED` activity Sent records the reason code `ERR_CONSENT_BLOCKED` internally and does not return it in API responses or webhook payloads. `FILTERED` also covers routing deny rules, so read the contact's `opt_out` field to confirm a consent block. Either way, respect the recipient's preference and do not retry. ```typescript // The send is accepted even when the recipient has opted out. const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); const messageId = response.data.recipients[0].message_id; // The block arrives on the message.filtered webhook. For a one-off check, // read the status back once the message is finalized. const message = await client.messages.retrieveStatus(messageId); if (message.data.status === 'FILTERED') { // FILTERED also covers routing deny rules, so confirm the opt-out on the // contact record before mirroring it into your own database. const contacts = await client.contacts.list({ phone: '+1234567890' }); if (contacts.data.contacts[0]?.opt_out) { await db.users.update({ where: { phone: '+1234567890' }, data: { messagingOptOut: true } }); console.log('Recipient opted out, skipping future sends'); } } ``` ```python # The send is accepted even when the recipient has opted out. response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) message_id = response.data.recipients[0].message_id # The block arrives on the message.filtered webhook. For a one-off check, # read the status back once the message is finalized. message = client.messages.retrieve_status(message_id) if message.data.status == 'FILTERED': # FILTERED also covers routing deny rules, so confirm the opt-out on the # contact record before mirroring it into your own database. contacts = client.contacts.list(phone='+1234567890') if contacts.data.contacts and contacts.data.contacts[0].opt_out: db.users.update( phone='+1234567890', messaging_opt_out=True ) print('Recipient opted out, skipping future sends') ``` --- ### Template Errors #### `400 Bad Request` - Template not found **Symptoms:** ``` BadRequestError: 400 - Template not found ``` **Solutions:** ```typescript // List all templates to find the correct ID try { const templates = await client.templates.list({ page: 1, page_size: 100 }); templates.data.templates.forEach(t => { console.log(`${t.name}: ${t.id} (${t.status})`); }); } catch (error) { console.error('Failed to list templates:', error.message); } ``` ```python # List all templates to find the correct ID try: templates = client.templates.list(page=1, page_size=100) for t in templates.data.templates: print(f'{t.name}: {t.id} ({t.status})') except Exception as e: print(f'Failed to list templates: {e}') ``` ```typescript try { const template = await client.templates.retrieve('your-template-id'); console.log('Status:', template.data.status); // APPROVED, PENDING, REJECTED console.log('Channels:', template.data.channels); } catch (error) { console.error('Template not found:', error.message); } ``` ```python try: template = client.templates.retrieve('your-template-id') print(f'Status: {template.data.status}') # APPROVED, PENDING, REJECTED print(f'Channels: {template.data.channels}') except Exception as e: print(f'Template not found: {e}') ``` #### `400 Bad Request` - WhatsApp template pending **Symptoms:** ``` BadRequestError: 400 - Template is not approved for WhatsApp ``` **Solutions:** 1. **Check template status in dashboard** - WhatsApp templates need Meta approval (can take hours) - SMS templates work immediately 2. **Use SMS as fallback** ```typescript // Try sending (may throw if template not approved) try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log('Sent:', response.data.recipients[0].message_id); } catch (error) { if (error instanceof SentDm.BadRequestError && error.message.includes('not approved')) { console.log('Template not approved yet'); // Queue for later or use alternative channel await db.queuedMessages.create({ phoneNumber: '+1234567890', templateId: 'welcome-template', retryAfter: new Date(Date.now() + 3600000), // 1 hour status: 'pending_approval' }); } } ``` ```python from sent_dm import BadRequestError # Try sending (may raise if template not approved) try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) print(f'Sent: {response.data.recipients[0].message_id}') except BadRequestError as e: if 'not approved' in str(e): print('Template not approved yet') # Queue for later or use alternative channel db.queued_messages.create( phone_number='+1234567890', template_id='welcome-template', retry_after=datetime.now() + timedelta(hours=1), status='pending_approval' ) ``` --- ### Rate Limiting #### `429 Rate Limit` - Too many requests **Symptoms:** ``` RateLimitError: 429 - Rate limit exceeded Retry-After: 60 ``` **Solutions:** SDKs have built-in retry logic, but you can also handle it manually: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm({ maxRetries: 3 // Built-in retry with exponential backoff }); // Or handle manually try { const response = await client.messages.send(params); } catch (error) { if (error instanceof SentDm.RateLimitError) { const retryAfter = parseInt( error.headers.get('retry-after') || '60', 10 ); console.log(`Rate limited. Retrying after ${retryAfter}s...`); await sleep(retryAfter * 1000); // Retry const response = await client.messages.send(params); } } ``` ```python import time from sent_dm import Sent, RateLimitError client = Sent(max_retries=3) # Built-in retry with exponential backoff # Or handle manually try: response = client.messages.send(...) except RateLimitError as e: retry_after = int(e.response.headers.get('retry-after', 60)) print(f'Rate limited. Retrying after {retry_after}s...') time.sleep(retry_after) # Retry response = client.messages.send(...) ``` ```typescript import pLimit from 'p-limit'; // Limit to 5 concurrent requests const limit = pLimit(5); const results = await Promise.all( recipients.map(phone => limit(() => client.messages.send({ to: [phone], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'notification' } })) ) ); ``` Default rate limits per customer account: - **Standard endpoints** (including `POST /v3/messages`): 200 requests per minute - **Sensitive endpoints** (webhook secret rotation and test delivery): 10 requests per minute Limits are enforced over a rolling 60-second window, so bursts and sustained traffic draw from the same budget: 200 requests in the first second uses the full minute's allowance, and later requests receive `429` responses until earlier requests age out of the window. Refer to the [rate limits reference](/reference/api/rate-limits) for headers, tiers, and backoff strategies. Contact support if you need higher limits. --- ### Billing Errors #### Payment Required - Account balance too low **Symptoms:** ``` BadRequestError: 400 - Insufficient credits to send message ``` **Solutions:** 1. **Add credits in the dashboard** - Go to [Billing](https://app.sent.dm/dashboard/billing) 2. **Graceful degradation** ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); } catch (error) { if (error instanceof SentDm.BadRequestError && error.message.includes('credits')) { // Queue for later await db.messageQueue.create({ ...messageData, status: 'pending_credits' }); await notifyOpsTeam('Account balance low'); } } ``` ```python from sent_dm import BadRequestError try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) except BadRequestError as e: if 'credits' in str(e): # Queue for later db.message_queue.create( status='pending_credits', **message_data ) notify_ops_team('Account balance low') ``` --- ## Webhook Issues ### Webhook not receiving events ```bash # Test if your endpoint is reachable curl -X POST https://your-app.com/webhooks/sent \ -H "Content-Type: application/json" \ -d '{"test": true}' ``` Make sure your endpoint: - Is publicly accessible (not localhost) - Uses HTTPS (required) - Returns 200 OK quickly Common mistakes: - Using the wrong secret - Not using raw request body - Case-sensitive header names SDKs do not ship a `verifySignature` helper: verify the HMAC signature manually. The signing secret (from the Sent Dashboard) has a `whsec_` prefix; strip it and base64-decode the remainder to get the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```typescript import { createHmac, timingSafeEqual } from 'crypto'; // ❌ Wrong - use raw body, not parsed JSON app.post('/webhooks/sent', (req, res) => { const payload = req.body; // Parsed JSON won't work // ... }); // ✅ Correct - use raw body app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body as Buffer; // Raw bytes const webhookId = req.headers['x-webhook-id'] as string; const timestamp = req.headers['x-webhook-timestamp'] as string; const signature = req.headers['x-webhook-signature'] as string; const secret = process.env.SENT_DM_WEBHOOK_SECRET!; const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signed = `${webhookId}.${timestamp}.${payload.toString('utf8')}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signed).digest('base64'); if (!signature || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).json({ error: 'Invalid signature' }); } res.json({ received: true }); } ); ``` ```python import hmac import hashlib import base64 @app.route('/webhooks/sent', methods=['POST']) def webhook(): webhook_id = request.headers.get('X-Webhook-ID', '') timestamp = request.headers.get('X-Webhook-Timestamp', '') signature = request.headers.get('X-Webhook-Signature', '') payload = request.get_data() # Raw bytes, not parsed JSON secret = os.environ['SENT_DM_WEBHOOK_SECRET'] key_bytes = base64.b64decode(secret.removeprefix('whsec_')) signed = f"{webhook_id}.{timestamp}.{payload.decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest() expected = 'v1,' + base64.b64encode(digest).decode() if not hmac.compare_digest(signature, expected): return jsonify({'error': 'Invalid signature'}), 401 return jsonify({'received': True}) ``` Make sure your webhook subscription covers the events you expect. Sent publishes two parent event types; each delivery carries the parent in the `field` property and, for message events, the granular sub-type in the `event` property: - `message`: outbound status updates (`message.queued`, `message.routed`, `message.scheduled`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.filtered`, `message.blocked`) and inbound messages (`message.received`) - `templates`: template status changes such as WhatsApp approval or rejection, with the outcome in `payload.status` There is no `message.status.updated` event. Subscribe to the `message` parent to receive every message event, or filter to specific sub-types. Refer to the [events reference](/start/webhooks/event-types) for the full catalog and payload schemas. ### Duplicate webhook events Webhook events may be delivered multiple times. Handle them idempotently: ```typescript async function handleWebhook(event: WebhookEvent) { const eventId = event.meta?.request_id || event.id; // Check if already processed const existing = await db.processedEvents.findUnique({ where: { eventId } }); if (existing) { console.log(`Event ${eventId} already processed`); return { received: true }; } // Process event... // Mark as processed await db.processedEvents.create({ data: { eventId, processedAt: new Date() } }); } ``` --- ## Connection Issues ### Timeout errors **Symptoms:** ``` APIConnectionError: Request timeout after 30000ms ``` **Solutions:** 1. **Increase timeout** ```typescript const client = new SentDm({ timeout: 60000 // 60 seconds }); ``` ```python from sent_dm import Sent client = Sent(timeout=60.0) # 60 seconds ``` ```go client := sentdm.NewClient( option.WithTimeout(60 * time.Second), ) ``` 2. **Check network connectivity** ```bash # Test API reachability (liveness probe, no authentication required) curl https://api.sent.dm/health/live ``` 3. **Implement circuit breaker** ```typescript class CircuitBreaker { private failures = 0; private lastFailureTime?: number; private readonly threshold = 5; private readonly timeout = 60000; async execute(fn: () => Promise): Promise { if (this.isOpen()) { throw new Error('Circuit breaker is open'); } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private isOpen(): boolean { if (this.failures < this.threshold) return false; if (!this.lastFailureTime) return false; return Date.now() - this.lastFailureTime < this.timeout; } private onSuccess() { this.failures = 0; } private onFailure() { this.failures++; this.lastFailureTime = Date.now(); } } ``` --- ## Debugging Tips ### Enable Debug Logging Most SDKs support debug logging via environment variables: ```bash # Set environment variable export SENT_LOG=debug ``` Or configure in code: ```typescript const client = new SentDm({ logLevel: 'debug' // 'debug', 'info', 'warn', 'error', 'off' }); ``` ```bash # Set environment variable export SENT_LOG=debug ``` Or use Python logging: ```python import logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger('sent_dm') logger.setLevel(logging.DEBUG) ``` ```go // Use the debug option client := sentdm.NewClient( option.WithDebugLog(nil), ) ``` ```bash # Set the SENT_LOG environment variable SENT_LOG=debug java -jar myapp.jar ``` ### Log Request IDs Always log information for support tickets: ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log('Message sent:', response.data.recipients[0].message_id); } catch (error) { if (error instanceof SentDm.APIError) { console.log('Request ID:', error.headers.get('x-request-id')); console.log('Status:', error.status); console.log('Message:', error.message); // Include these in support tickets! } } ``` ```python from sent_dm import APIStatusError try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) print(f'Message sent: {response.data.recipients[0].message_id}') except APIStatusError as e: print(f'Request ID: {e.response.headers.get("x-request-id")}') print(f'Status: {e.status_code}') print(f'Message: {str(e)}') # Include these in support tickets! ``` ```go response, err := client.Messages.Send(ctx, params) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { fmt.Printf("Request ID: %s\n", apiErr.Response.Header.Get("x-request-id")) fmt.Printf("Status: %d\n", apiErr.StatusCode) fmt.Printf("Message: %s\n", apiErr.Error()) // Include these in support tickets! } } else { fmt.Printf("Message sent: %s\n", response.Data.Recipients[0].MessageID) } ``` ### Test with cURL Compare SDK behavior with raw API calls: ```bash # Test authentication curl -X GET https://api.sent.dm/v3/templates \ -H "x-api-key: YOUR_API_KEY" # Test message sending curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "template": {"id": "your-template-id"} }' ``` --- ## Frequently Asked Questions ### Q: Why is a message stuck in "QUEUED" status? **A:** Outbound messages start as `QUEUED` and transition through: 1. `QUEUED` → Message accepted and queued for processing 2. `PROCESSED` → Message processed and queued for sending 3. `ROUTED` → Message routed to a channel provider 4. `SENT` → Dispatched to carrier/WhatsApp 5. `DELIVERED` → Confirmed delivery 6. `READ` → (WhatsApp & RCS) Opened by recipient Messages that cannot be delivered end in `FAILED`. Webhooks are recommended over polling for tracking status changes. If you need to check a single message, you can poll `GET /v3/messages/{id}` (`client.messages.retrieveStatus()` in the SDKs). ### Q: Can you use the same API key for multiple environments? **A:** Yes, but it's not recommended. Use separate keys for different environments and explicitly pass them to the SDK: ```typescript // Choose key based on environment const apiKey = process.env.NODE_ENV === 'production' ? process.env.SENT_DM_API_KEY : process.env.SENT_DM_API_KEY_TEST; const client = new SentDm({ apiKey }); ``` This prevents accidental sends from test environments. Note: The SDK only automatically reads `SENT_DM_API_KEY` - you must implement the environment switching logic yourself. ### Q: Why do 401 errors appear in production but not locally? **A:** Common causes: 1. Different API keys (check environment variables) 2. Key not set in production environment 3. Key was revoked/rotated 4. Whitespace or encoding issues Debug by logging the key prefix: ```typescript console.log('Key prefix:', process.env.SENT_DM_API_KEY?.substring(0, 8)); ``` ### Q: How do you handle webhook failures? **A:** Sent retries failed deliveries with exponential backoff, up to your webhook's configured retry count (1–5 attempts, default 3). Once the retry budget is exhausted, the event is marked `FAILED` and can be inspected in the Sent Dashboard. Ensure your endpoint is idempotent and responds quickly. Refer to [handling retries](/start/webhooks/handling-retries) for the full retry model. ### Q: Can you send messages from the browser? **A:** No. Never expose your API key in client-side code. API keys should only be used server-side. For browser-based messaging, route through your backend API. --- ## Getting Help If you're still stuck: 1. **Check the [API Reference](/reference/api)** for detailed endpoint documentation 2. **Review [SDK Guides](/sdks)** for language-specific examples 3. **Contact Support** with your request ID: - email: support@sent.dm - Include: Request ID from error response (in `x-request-id` header) - Include: Timestamp of the failed request - Include: Code snippet (remove API keys!) When contacting support, always include the request ID from the API response headers (`x-request-id`). This lets Sent support trace the exact request in the delivery logs. --- ## SDK-Specific Error Patterns Different SDKs handle errors differently. Here's a quick reference: | SDK | Error Pattern | Key Exception Types | |-----|---------------|---------------------| | **TypeScript** | Throws exceptions | `APIError`, `BadRequestError`, `RateLimitError`, `AuthenticationError` | | **Python** | Throws exceptions | `APIError`, `BadRequestError`, `RateLimitError`, `AuthenticationError` | | **Go** | Returns error value | `*sentdm.Error` with `StatusCode`, `Error()`, `RawJSON()` | | **Java** | Throws exceptions | `SentException`, `BadRequestException`, `RateLimitException` | | **C#** | Throws exceptions | `SentApiException`, `SentBadRequestException`, `SentRateLimitException` | | **PHP** | Throws exceptions | `APIException`, `BadRequestException`, `RateLimitException` | | **Ruby** | Throws exceptions | `APIError`, `BadRequestError`, `RateLimitError` | --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/first-integration.txt TITLE: Your first Sent integration with the TypeScript SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/first-integration.txt Learn Sent by building a real TypeScript integration: send sandbox messages, receive delivery webhooks on your machine, and look up message status with the SDK. # Your first Sent integration with the TypeScript SDK In this tutorial, you build a small TypeScript project that exercises the full Sent messaging loop: send a free-form text message, send a template message, receive `message.delivered` and `message.failed` webhooks on your own machine, and look up a message with the SDK. It takes about 30 minutes. Every send in this tutorial runs in [sandbox mode](/reference/api/test-mode): the API validates and simulates each request but delivers nothing and consumes no credits. You can complete every step on a brand-new Sent account. ## Prerequisites Before you begin, make sure you have: - **Node.js 20 or later**: check with `node --version` - **A Sent API key**: copy an existing key, or generate one, on the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - **ngrok**: install it with `npm install -g @ngrok/ngrok`; you use it to receive webhooks locally, and the [webhook local development guide](/start/webhooks/local-development) explains why you need a tunnel - **curl**: preinstalled on macOS, Linux, and Windows 10+ ### Set up the project First, create a new project and install the SDK, Express (for the webhook receiver), and tsx (to run TypeScript files directly): ```bash mkdir sent-first-integration cd sent-first-integration npm init -y npm install @sentdm/sentdm express npm install -D tsx ``` Now export your API key so the SDK can find it. The client reads the `SENT_DM_API_KEY` environment variable by default: ```bash export SENT_DM_API_KEY="your-api-key" ``` Let's check the install worked: ```bash npm ls @sentdm/sentdm ``` The output should look something like: ``` sent-first-integration@1.0.0 └── @sentdm/sentdm@0.32.0 ``` Keep API keys out of version control. Environment variables are the pattern we use throughout this tutorial. ### Send a free-form text message Now we send our first message. Create a file named `send-text.ts`: ```typescript title="send-text.ts" import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // reads SENT_DM_API_KEY async function main() { const response = await client.messages.send({ to: ['+14155551234'], // replace with your own number in E.164 format text: 'Hello from my first Sent integration!', sandbox: true, // validate and simulate: nothing is delivered }); console.log(JSON.stringify(response, null, 2)); } main(); ``` Replace `+14155551234` with your own mobile number in E.164 format, then run it: ```bash npx tsx send-text.ts ``` The output should look something like: ```json { "success": true, "data": { "status": "QUEUED", "template_id": "00000000-0000-0000-0000-000000000000", "template_name": "", "recipients": [ { "message_id": "3f2b8c41-6a0e-4c8f-9b7d-2a1e5c9d0f36", "to": "+14155551234", "channel": null, "body": null } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-07-25T09:30:00.0000000+00:00", "version": "v3" } } ``` Copy the `message_id` value from your output. We use it again in the final step. Notice three things in the response: - `status` is `QUEUED`: the API accepts sends asynchronously (HTTP 202) and reports delivery later, through webhooks. - `channel` is `null` because we did not pick one. Sent auto-detects the best channel per recipient at send time. - `template_id` is all zeros and `template_name` is empty because this was a free-form `text` send. Every request must carry exactly one of `text` or `template`. Validation runs on sandbox requests too, so a malformed payload returns a real 400 error. ### Send a template message Channels like WhatsApp and RCS require approved templates for production messaging, so let's send one. Create `send-template.ts`: ```typescript title="send-template.ts" import SentDm from '@sentdm/sentdm'; const client = new SentDm(); async function main() { const response = await client.messages.send({ to: ['+14155551234'], // your number again channel: ['sms'], template: { name: 'welcome', parameters: { name: 'Ada' }, }, sandbox: true, }); console.log(JSON.stringify(response.data, null, 2)); } main(); ``` Run it: ```bash npx tsx send-template.ts ``` The output should look something like: ```json { "status": "QUEUED", "template_id": "00000000-0000-0000-0000-000000000000", "template_name": "welcome", "recipients": [ { "message_id": "9c41d2aa-7b3f-4e58-8f06-5d2e91c7ab10", "to": "+14155551234", "channel": "sms", "body": null } ] } ``` Notice that this time each recipient carries `channel: "sms"`. When you list several channels, Sent creates a separate message (with its own `message_id`) for every recipient-and-channel pair. We reference the template by `name` here; a template reference takes either `id` or `name`, never both. In sandbox mode the template is not looked up, so any name passes. A live send requires a template that exists on your account, and returns a 404 `RESOURCE_002` (template not found) error otherwise. You have now sent messages both ways the API supports. Next, let's find out what happens to a message after it is queued. ### Start a local webhook receiver Sent reports delivery progress by POSTing events to your server. We start with a minimal receiver. Create `webhook-server.ts`: ```typescript title="webhook-server.ts" import express from 'express'; const app = express(); // Raw body: signature verification in production needs the exact bytes app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const eventType = req.header('x-webhook-event-type'); const event = JSON.parse(req.body.toString('utf8')); console.log(`--- ${eventType} ---`); console.log(JSON.stringify(event, null, 2)); res.status(200).json({ received: true }); }); app.listen(3000, () => { console.log('Webhook receiver listening on http://localhost:3000'); }); ``` Run it in its own terminal and leave it running: ```bash npx tsx webhook-server.ts ``` The output should look something like: ``` Webhook receiver listening on http://localhost:3000 ``` We parse the raw body instead of using `express.json()` because production handlers must verify the `X-Webhook-Signature` header against the exact request bytes, as shown in [webhook signature verification](/start/webhooks/signature-verification). We skip verification here to stay focused on the flow. ### Open a tunnel with ngrok Sent cannot reach `localhost`, and it rejects webhook URLs that resolve to private or loopback addresses. A tunnel gives your local server a public HTTPS URL. In a second terminal, run: ```bash ngrok http 3000 ``` The output should include a forwarding line like: ``` https://abc123.ngrok.io -> http://localhost:3000 ``` Copy your `https://` forwarding URL. We register it with Sent in the next step. Keep this terminal running too. ### Register the webhook Now we tell Sent where to deliver events. In a third terminal, call the webhooks API with your forwarding URL: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "First integration tutorial", "endpoint_url": "https://abc123.ngrok.io/webhooks/sent", "event_types": ["message"] }' ``` The output should look something like: ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "First integration tutorial", "endpoint_url": "https://abc123.ngrok.io/webhooks/sent", "signing_secret": "whsec_a1b2c3d4e5f6g7h8i9j0", "is_active": true, "event_types": ["message"], "retry_count": 3, "timeout_seconds": 30 }, "error": null } ``` Copy the `id` from your response. We need it in the next step. Notice that `event_types` takes parent categories: `message` covers every message event, from `message.queued` through `message.delivered`, `message.failed`, and inbound `message.received` (the [webhook event types page](/start/webhooks/event-types) has the full list). The `signing_secret` is what production code uses to verify the `X-Webhook-Signature` header; treat it like a password. ### Deliver message.delivered and message.failed events With the receiver, the tunnel, and the registration in place, we can ask Sent to deliver a test event. Replace the webhook ID in the URL with the `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` The curl output should look something like: ```json { "success": true, "data": { "success": true, "message": "Test event delivered successfully" } } ``` Now look at the terminal running `webhook-server.ts`. The output should look something like: ``` --- message.delivered --- { "field": "message", "event": "message.delivered", "timestamp": "2026-07-25T09:30:00Z", "payload": { "updated_at": "2026-07-25T09:30:00Z", "account_id": "00000000-0000-0000-0000-000000000000", "message_id": "00000000-0000-0000-0000-000000000001", "template_id": "00000000-0000-0000-0000-000000000002", "template_name": "Test Template", "outbound_number": "+0987654321", "message_status": "DELIVERED", "channel": "sms" } } ``` Sent just made a round trip to your laptop. Run the same command again with `{"event_type": "message.failed"}` in the body. Your receiver prints a second event, identical in shape but with `"message_status": "FAILED"`. Notice that test events carry placeholder IDs (the zero-padded values above). Events for real sends carry the same `message_id` your send call returned; that ID is how your application matches a delivery report back to the message it sent. Sent attempts a test event exactly once, and retries real events up to the webhook's `retry_count` on failure. ### Look up a message with the SDK Finally, let's read a message back. Create `check-status.ts`: ```typescript title="check-status.ts" import SentDm from '@sentdm/sentdm'; const client = new SentDm(); async function main() { const messageId = process.argv[2]; try { const status = await client.messages.retrieveStatus(messageId); console.log('Status:', status.data.status); console.log('Channel:', status.data.channel); console.log('Direction:', status.data.direction); } catch (err) { if (err instanceof SentDm.APIError) { console.log(`${err.constructor.name} (${err.status})`); } else { throw err; } } } main(); ``` Run it with the `message_id` you copied in the free-form text step: ```bash npx tsx check-status.ts 3f2b8c41-6a0e-4c8f-9b7d-2a1e5c9d0f36 ``` The output should be: ``` NotFoundError (404) ``` That 404 is expected, and it is the last lesson of this tutorial. Sandbox messages are simulated, never stored, so there is nothing to look up. The `catch` block you just wrote is the same `APIError` handling your production code needs for every SDK call. For a live (non-sandbox) message, the identical script prints the real delivery state: ``` Status: DELIVERED Channel: sms Direction: OUTBOUND ``` Remember that webhooks, not lookups, are the recommended way to track delivery: store the `message_id` when you send, then update your records as events arrive instead of polling this endpoint. ## What you have built You have built a working end-to-end Sent integration: a project that sends free-form and template messages through the TypeScript SDK, a webhook receiver that accepts signed delivery events from Sent's servers on your own machine, and a status lookup with real error handling. You have also learned the ideas that carry into production (asynchronous sends, per-recipient message IDs, parent event types, and webhook-first delivery tracking) without spending a credit. ## Next steps Take the loop you built here to production: - Swap `sandbox: true` for real sends once your [account setup](/start/quickstart/account-setup) is complete; the code doesn't change - Work through the [webhooks production checklist](/start/webhooks/production-checklist) and add [signature verification](/start/webhooks/signature-verification) to your receiver - Wire the client into your framework with the [Express](/sdks/typescript/integrations/express), [NestJS](/sdks/typescript/integrations/nestjs), or [Next.js](/sdks/typescript/integrations/nextjs) integration guides - Read the [error handling reference](/reference/api/errors) and the [sandbox mode reference](/reference/api/test-mode) for what you used today ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript.txt TITLE: TypeScript SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript.txt Official TypeScript SDK for Sent. Send SMS, WhatsApp, and RCS messages with full type safety and intelligent autocomplete. # TypeScript SDK The official TypeScript SDK for Sent provides type-safe access to the entire Sent API. Built for modern Node.js applications with native ESM and CommonJS support, automatic retries, and error handling. ## Installation ```bash npm install @sentdm/sentdm ``` ```bash yarn add @sentdm/sentdm ``` ```bash pnpm add @sentdm/sentdm ``` ```bash bun add @sentdm/sentdm ``` ## Quick Start ### Initialize the client ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // Uses SENT_DM_API_KEY env var by default ``` ### Send your first message ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome', parameters: { name: 'John Doe' } } }); console.log('Message sent:', response.data.recipients[0].message_id); console.log('Status:', response.data.status); ``` **New to Sent?** Follow [Your first Sent integration with the TypeScript SDK](/sdks/typescript/first-integration), a guided tutorial that takes you from an empty project to a sandbox send, delivery webhooks on your own machine, and a status lookup. ## Authentication The client can be configured using environment variables or explicitly: ```typescript import SentDm from '@sentdm/sentdm'; // Using environment variables (recommended) // SENT_DM_API_KEY=your_api_key const client = new SentDm(); // Or explicit configuration const client = new SentDm({ apiKey: 'your_api_key', }); ``` ## Send Messages ### Send a message ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome', parameters: { name: 'John Doe', order_id: '12345' } }, channel: ['whatsapp', 'sms', 'rcs'] // Optional. Defaults to ["sent"], which auto-detects the channel per recipient }); console.log('Message ID:', response.data.recipients[0].message_id); console.log('Status:', response.data.status); ``` ### Sandbox mode Use `sandbox: true` to validate requests without sending real messages: ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, sandbox: true // Validates but doesn't send }); // Response will have test data console.log('Validation passed:', response.data.recipients[0].message_id); ``` ## Check message status Retrieve the current status of a sent message. The `direction` field indicates whether the message is `"OUTBOUND"` (sent by you) or `"INBOUND"` (a reply or opt-out keyword received from an end user): ```typescript const status = await client.messages.retrieveStatus('msg-uuid'); console.log('Status:', status.data.status); // e.g. "DELIVERED" console.log('Channel:', status.data.channel); // e.g. "sms" console.log('Direction:', status.data.direction); // "OUTBOUND" | "INBOUND" ``` ## Message activities Retrieve the full activity log for a message, useful for auditing delivery attempts across carriers: ```typescript const activities = await client.messages.retrieveActivities('msg-uuid'); for (const activity of activities.data.activities) { console.log(`${activity.timestamp}: ${activity.status} via ${activity.from}`); console.log(' Price:', activity.price); console.log(' Active contact price:', activity.active_contact_price); } ``` ## Numbers Look up carrier and line-type information for any phone number before sending: ```typescript const result = await client.numbers.lookup('+12025551234'); console.log('Valid:', result.data.is_valid); console.log('Carrier:', result.data.carrier_name); console.log('Line type:', result.data.line_type); // "mobile", "landline", "voip" console.log('VoIP:', result.data.is_voip); ``` ## Handle errors When the library is unable to connect to the API, or if the API returns a non-success status code (that is, 4xx or 5xx response), a subclass of `APIError` will be thrown: ```ts try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); } catch (err) { if (err instanceof SentDm.APIError) { console.log(err.status); // 400 console.log(err.constructor.name); // BadRequestError console.log(err.headers); // {server: 'nginx', ...} } else { throw err; } } ``` Error codes are as follows: | Cause | Error Type | |-------|--------------------------| | HTTP 400 | `BadRequestError` | | HTTP 401 | `AuthenticationError` | | HTTP 403 | `PermissionDeniedError` | | HTTP 404 | `NotFoundError` | | HTTP 409 | `ConflictError` | | HTTP 422 | `UnprocessableEntityError` | | HTTP 429 | `RateLimitError` | | HTTP >= 500 | `InternalServerError` | | Timeout | `APIConnectionTimeoutError` | | Network error | `APIConnectionError` | ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. Connection errors, 408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errors will all be retried by default. ```js // Configure the default for all requests: const client = new SentDm({ maxRetries: 0, // default is 2 }); // Or, configure per-request: await client.messages.send({ to: ['+1234567890'], template: { id: 'welcome-template', name: 'welcome' } }, { maxRetries: 5, }); ``` ## Timeouts Requests time out after 1 minute by default. You can configure this with a `timeout` option: ```ts // Configure the default for all requests: const client = new SentDm({ timeout: 20 * 1000, // 20 seconds (default is 1 minute) }); // Override per-request: await client.messages.send({ to: ['+1234567890'], template: { id: 'welcome-template', name: 'welcome' } }, { timeout: 5 * 1000, }); ``` ## Contacts Create and manage contacts: ```typescript // Create a contact const contact = await client.contacts.create({ phone_number: '+1234567890', }); console.log('Contact ID:', contact.data.id); // List contacts const contacts = await client.contacts.list({ page: 1, page_size: 100, }); console.log('Total:', contacts.data.contacts.length); // Get a contact const retrieved = await client.contacts.retrieve('contact-uuid'); // Update a contact const updated = await client.contacts.update('contact-uuid', { default_channel: 'whatsapp', }); // Delete a contact await client.contacts.delete('contact-uuid', {}); ``` ## Templates List and retrieve templates: ```typescript // List all templates const templates = await client.templates.list({ page: 1, page_size: 100 }); for (const template of templates.data.templates) { console.log(`${template.name} (${template.status}): ${template.id}`); } // Get a specific template const template = await client.templates.retrieve('template-uuid'); console.log('Template name:', template.data.name); console.log('Status:', template.data.status); ``` ## Framework Integration ### NestJS See the [NestJS Integration](/sdks/typescript/integrations/nestjs) guide for complete dependency injection, module setup, and testing examples. ```typescript // messages/messages.service.ts import { Injectable, Inject } from '@nestjs/common'; import SentDm from '@sentdm/sentdm'; import { SENT_CLIENT } from '../sent/sent.module'; @Injectable() export class MessagesService { constructor( @Inject(SENT_CLIENT) private readonly sentClient: SentDm, ) {} async sendWelcomeMessage(phoneNumber: string, name: string) { const response = await this.sentClient.messages.send({ to: [phoneNumber], template: { id: 'welcome-template', name: 'welcome', parameters: { name } } }); return response.data.recipients[0]; } } ``` ### Next.js (App Router) See the [Next.js Integration](/sdks/typescript/integrations/nextjs) guide for complete Server Actions, Route Handlers, and Edge Runtime examples. ```typescript // app/api/send-message/route.ts import SentDm from '@sentdm/sentdm'; import { NextResponse } from 'next/server'; const client = new SentDm(); export async function POST(request: Request) { const { phoneNumber, templateId, variables } = await request.json(); try { const response = await client.messages.send({ to: [phoneNumber], template: { id: templateId, name: 'welcome', parameters: variables } }); return NextResponse.json({ messageId: response.data.recipients[0].message_id, status: response.data.status, }); } catch (error) { if (error instanceof SentDm.APIError) { return NextResponse.json( { error: error.message }, { status: error.status } ); } throw error; } } ``` ### Express.js See the [Express.js Integration](/sdks/typescript/integrations/express) guide for complete dependency injection, validation, and structured logging examples. ```typescript import express from 'express'; import SentDm from '@sentdm/sentdm'; const app = express(); const client = new SentDm(); app.use(express.json()); app.post('/send-message', async (req, res) => { const { phoneNumber, templateId, variables } = req.body; try { const response = await client.messages.send({ to: [phoneNumber], template: { id: templateId, name: 'welcome', parameters: variables } }); res.json({ success: true, message: response.data, }); } catch (error) { if (error instanceof SentDm.APIError) { res.status(error.status).json({ error: error.message }); } else { res.status(500).json({ error: 'Internal server error' }); } } }); app.listen(3000); ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery, so don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`message`**: Message status changes (`QUEUED`, `ROUTED`, `SCHEDULED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `FILTERED`, `BLOCKED`, `RECEIVED`); each fires as a sub-type (for example, `message.delivered`, `message.filtered`). Use `message.received` to receive inbound messages from contacts. - **`templates`**: WhatsApp template approval/rejection Every request includes these headers: | Header | Description | |--------|-------------| | `X-Webhook-ID` | UUID of the webhook configuration | | `X-Webhook-Timestamp` | Unix timestamp (seconds) when the event was created | | `X-Webhook-Signature` | `v1,{base64}`: HMAC-SHA256 of `{webhookId}.{timestamp}.{rawBody}` | | `X-Webhook-Event-Type` | `message` (for example, `message.delivered`) or `templates` | The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. ```typescript import express from 'express'; import { createHmac, timingSafeEqual } from 'crypto'; const app = express(); // Must use raw body — do NOT use express.json() for this route app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body as Buffer; const webhookId = req.headers['x-webhook-id'] as string; const timestamp = req.headers['x-webhook-timestamp'] as string; const signature = req.headers['x-webhook-signature'] as string; // 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" const secret = process.env.SENT_DM_WEBHOOK_SECRET!; // "whsec_abc123..." const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signed = `${webhookId}.${timestamp}.${payload.toString('utf8')}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signed).digest('base64'); if (!signature || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).json({ error: 'Invalid signature' }); } // 2. Optional: reject replayed events older than 5 minutes if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.status(401).json({ error: 'Timestamp too old' }); } const event = JSON.parse(payload.toString()); // 3. Handle events — update message status in your own database if (event.field === 'message') { if (event.event === 'message.received') { // Inbound message from a contact const { inbound_number, outbound_number, text, channel, received_at } = event.payload; console.log(`Inbound ${channel} from ${inbound_number}: ${text}`); // await db.inboundMessages.create({ from: inbound_number, to: outbound_number, text, channel, receivedAt: received_at }); } else { // Outbound message status update const { message_id, message_status, channel } = event.payload; // await db.messages.update({ where: { sentId: message_id }, data: { status: message_status } }) console.log(`Message ${message_id} → ${message_status} (${channel})`); } } // 4. Always return 200 quickly res.json({ received: true }); }); ``` See the [Webhooks reference](/start/webhooks) for the full payload schema and all status values. ## Logging The log level can be configured via the `SENT_LOG` environment variable or using the `logLevel` client option: ```ts import SentDm from '@sentdm/sentdm'; const client = new SentDm({ logLevel: 'debug', // Show all log messages }); ``` Available log levels: `'debug'`, `'info'`, `'warn'` (default), `'error'`, `'off'` ## Source & Issues - **Releases**: [GitHub Releases](https://github.com/sentdm/sent-dm-typescript/releases) - **GitHub**: [`sentdm/sent-dm-typescript`](https://github.com/sentdm/sent-dm-typescript) - **NPM**: [`@sentdm/sentdm`](https://www.npmjs.com/package/@sentdm/sentdm) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-typescript/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: email [support@sent.dm](mailto:support@sent.dm) with your request ID --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/integrations/express.txt TITLE: Sending messages from Express with the Sent TypeScript SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/integrations/express.txt Wire the Sent TypeScript SDK into an Express app: install, configure the client, send template messages from a route, verify webhooks, and test sandbox sends. # Sending messages from Express with the Sent TypeScript SDK This guide shows you how to wire Sent messaging into an existing Express app: install the TypeScript SDK, configure a shared client, send a template message from a route, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Express 5 app with TypeScript. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing project: ```bash npm install @sentdm/sentdm ``` ### Configure the client Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4: ```bash export SENT_DM_API_KEY="your-api-key" export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret" ``` Create one shared client for the whole process; `new SentDm()` reads `SENT_DM_API_KEY` by default: ```typescript // src/sent.ts import SentDm from '@sentdm/sentdm'; export const sent = new SentDm(); ``` ### Send a template message from a route Add a router that calls `messages.send`; the pass-through `sandbox` flag lets callers exercise the route without delivering anything: ```typescript // src/routes/messages.ts import { Router } from 'express'; import { sent } from '../sent'; export const messagesRouter = Router(); messagesRouter.post('/send', async (req, res, next) => { try { const { phoneNumber, templateName, parameters, channels, sandbox } = req.body; const response = await sent.messages.send({ to: [phoneNumber], // E.164 format, for example +14155551234 template: { name: templateName, parameters }, // reference by name or id, never both channel: channels, // omit to let Sent pick per recipient sandbox: sandbox ?? false, // true = validate and simulate only }); const recipient = response.data.recipients[0]; res.status(202).json({ messageId: recipient.message_id, status: response.data.status }); } catch (err) { next(err); } }); ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`, because delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Add a verification helper that checks the `X-Webhook-Signature` header against the raw request body before your handler trusts any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme: ```typescript // src/webhook-signature.ts import { createHmac, timingSafeEqual } from 'crypto'; // Signature format: "v1,{base64(hmac)}" over "{webhookId}.{timestamp}.{rawBody}" export function verifyWebhookSignature( payload: Buffer, webhookId: string, timestamp: string, signature: string, secret: string, ): boolean { const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signedContent = `${webhookId}.${timestamp}.${payload.toString('utf8')}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signedContent).digest('base64'); const signatureBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); return signatureBuffer.length === expectedBuffer.length && timingSafeEqual(signatureBuffer, expectedBuffer); } ``` Add the endpoint itself. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them; return 200 quickly and do slow work elsewhere: ```typescript // src/routes/webhooks.ts import { Router } from 'express'; import { verifyWebhookSignature } from '../webhook-signature'; export const webhooksRouter = Router(); webhooksRouter.post('/sent', (req, res) => { const webhookId = req.header('x-webhook-id') ?? ''; const timestamp = req.header('x-webhook-timestamp') ?? ''; const signature = req.header('x-webhook-signature') ?? ''; const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? ''; if (!signature || !secret || !verifyWebhookSignature(req.body, webhookId, timestamp, signature, secret)) { return res.status(401).json({ error: 'Invalid webhook signature' }); } // Reject replayed events older than 5 minutes if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.status(401).json({ error: 'Webhook timestamp too old' }); } const event = JSON.parse(req.body.toString('utf8')); const payload = event.payload ?? {}; if (event.field === 'message') { switch (event.event) { case 'message.delivered': console.log(`Message ${payload.message_id} delivered`); break; case 'message.failed': console.error(`Message ${payload.message_id} failed (status ${payload.message_status})`); break; case 'message.received': console.log(`Inbound ${payload.channel} from ${payload.inbound_number}: ${payload.text}`); break; default: console.log(`Message ${payload.message_id} status: ${payload.message_status}`); } } res.json({ received: true }); }); ``` Mount both routers. The webhook path must receive the raw body. Signature verification needs the exact bytes, so register `express.raw` for it, not `express.json`: ```typescript // src/app.ts (excerpt) app.use('/api/messages', express.json(), messagesRouter); app.use('/webhooks', express.raw({ type: 'application/json' }), webhooksRouter); ``` Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Express integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded (for example, `npm run dev`). Then send a sandbox message through your new route. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:3000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155551234", "templateName": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `messageId` and `"status": "QUEUED"`. A 400 here means the request shape is wrong; sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your server log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If your app already parses JSON globally with `app.use(express.json())`, scope it away from the webhook path, because verification fails on re-serialized bodies. - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and process asynchronously so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send free-form text instead of a template, pass `text` instead of `template`. Each send carries exactly one of the two. - If you need input validation, rate limiting, or structured logging around these routes, the appendix below has the scaffolding. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Express stack. Adapt them to your own conventions rather than adopting them wholesale. Fail fast at boot when required variables are missing instead of at first send: ```typescript // src/config/env.ts import { z } from 'zod'; const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.string().transform(Number).default('3000'), SENT_DM_API_KEY: z.string().min(1, 'SENT_DM_API_KEY is required'), SENT_DM_WEBHOOK_SECRET: z.string().min(1, 'SENT_DM_WEBHOOK_SECRET is required'), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error('Invalid environment variables:', parsed.error.format()); process.exit(1); } export const env = parsed.data; ``` Validate request bodies with the same library before they reach the SDK: ```typescript // src/types.ts import { z } from 'zod'; export const SendMessageSchema = z.object({ phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'), templateName: z.string().min(1).max(100), parameters: z.record(z.string()).optional(), channels: z.array(z.enum(['sms', 'whatsapp', 'rcs'])).optional(), sandbox: z.boolean().optional(), }); ``` Cap how often callers can hit your send route and add standard hardening middleware: ```typescript // src/app.ts (excerpt) import helmet from 'helmet'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; app.use(helmet()); app.use(cors({ origin: env.NODE_ENV === 'production' ? [/\.your-domain\.example$/] : true })); app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false })); ``` Close the HTTP server on SIGTERM so in-flight webhook deliveries finish instead of failing and retrying: ```typescript // src/server.ts (excerpt) const server = app.listen(env.PORT); const shutdown = () => { server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 10_000); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); ``` This vitest helper signs payloads exactly the way Sent does, so you can test verification offline; the [SDK testing guide](/sdks/testing) covers mocking `messages.send`: ```typescript // src/routes/webhooks.spec.ts import { createHmac } from 'crypto'; import request from 'supertest'; function signedHeaders(payload: string, secret: string) { const webhookId = '550e8400-e29b-41d4-a716-446655440000'; const timestamp = String(Math.floor(Date.now() / 1000)); const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signature = 'v1,' + createHmac('sha256', keyBytes) .update(`${webhookId}.${timestamp}.${payload}`).digest('base64'); return { 'x-webhook-id': webhookId, 'x-webhook-timestamp': timestamp, 'x-webhook-signature': signature }; } it('accepts a correctly signed event', async () => { const payload = JSON.stringify({ field: 'message', event: 'message.delivered', timestamp: new Date().toISOString(), payload: { message_id: 'msg_123', message_status: 'DELIVERED' } }); const res = await request(app).post('/webhooks/sent') .set(signedHeaders(payload, process.env.SENT_DM_WEBHOOK_SECRET!)) .set('content-type', 'application/json') .send(payload); expect(res.status).toBe(200); }); ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [TypeScript SDK reference](/sdks/typescript) for retries, timeouts, and error types - New to Sent? The [first integration tutorial](/sdks/typescript/first-integration) walks the same loop from scratch ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/integrations/nestjs.txt TITLE: Sending messages from NestJS with the Sent TypeScript SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/integrations/nestjs.txt Wire the Sent TypeScript SDK into a NestJS app: install, register a client provider, send from a controller, verify webhooks, and test with sandbox mode. # Sending messages from NestJS with the Sent TypeScript SDK This guide shows you how to wire Sent messaging into an existing NestJS app: install the TypeScript SDK, register the client as a provider, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working NestJS app that uses `@nestjs/config`. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing project: ```bash npm install @sentdm/sentdm ``` ### Configure the client provider Add the credentials to your `.env`; the webhook secret arrives in step 4: ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secret ``` Register one shared client as a global provider so any service can inject it: ```typescript // sent/sent.module.ts import { Global, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import SentDm from '@sentdm/sentdm'; export const SENT_CLIENT = Symbol('SENT_CLIENT'); @Global() @Module({ providers: [ { provide: SENT_CLIENT, useFactory: (config: ConfigService) => new SentDm({ apiKey: config.getOrThrow('SENT_DM_API_KEY') }), inject: [ConfigService], }, ], exports: [SENT_CLIENT], }) export class SentModule {} ``` Import `SentModule` in your `AppModule` alongside `ConfigModule.forRoot({ isGlobal: true })`. ### Send a template message from a controller Wrap the SDK call in a service; the pass-through `sandbox` flag lets callers exercise the endpoint without delivering anything: ```typescript // messages/messages.service.ts import { Inject, Injectable, Logger } from '@nestjs/common'; import SentDm from '@sentdm/sentdm'; import { SENT_CLIENT } from '../sent/sent.module'; export interface SendMessageDto { phoneNumber: string; // E.164 format, for example +14155551234 templateName: string; // reference by name or id, never both parameters?: Record; channels?: string[]; // omit to let Sent pick per recipient sandbox?: boolean; // true = validate and simulate only } @Injectable() export class MessagesService { private readonly logger = new Logger(MessagesService.name); constructor(@Inject(SENT_CLIENT) private readonly sentClient: SentDm) {} async sendMessage(dto: SendMessageDto) { const response = await this.sentClient.messages.send({ to: [dto.phoneNumber], template: { name: dto.templateName, parameters: dto.parameters ?? {} }, channel: dto.channels, sandbox: dto.sandbox ?? false, }); const recipient = response.data.recipients[0]; this.logger.log(`Message queued: ${recipient.message_id}`); return { messageId: recipient.message_id, status: response.data.status }; } } ``` Expose it through a controller: ```typescript // messages/messages.controller.ts import { Body, Controller, HttpCode, Post } from '@nestjs/common'; import { MessagesService, SendMessageDto } from './messages.service'; @Controller('api/messages') export class MessagesController { constructor(private readonly messagesService: MessagesService) {} @Post('send') @HttpCode(202) sendMessage(@Body() dto: SendMessageDto) { return this.messagesService.sendMessage(dto); } } ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`, because delivery outcomes arrive on your webhook endpoint instead of in this response. ### Receive delivery webhooks Enable raw body capture first, because signature verification needs the exact request bytes: ```typescript // main.ts (excerpt) const app = await NestFactory.create(AppModule, { rawBody: true }); ``` Add a controller that verifies the `x-webhook-signature` header before trusting any event. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them: ```typescript // webhooks/webhooks.controller.ts import { BadRequestException, Controller, Headers, Logger, Post, Req, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual } from 'crypto'; import type { Request } from 'express'; // With rawBody enabled, Nest exposes the unparsed bytes on the request type RawBodyRequest = Request & { rawBody?: Buffer }; interface WebhookEvent { field: 'message' | 'templates'; event?: string; // granular sub-type; omitted for template events timestamp: string; payload: Record; } @Controller('webhooks') export class WebhooksController { private readonly logger = new Logger(WebhooksController.name); constructor(private readonly configService: ConfigService) {} @Post('sent') async handleWebhook( @Headers('x-webhook-id') webhookId: string, @Headers('x-webhook-timestamp') timestamp: string, @Headers('x-webhook-signature') signature: string, @Req() req: RawBodyRequest, ): Promise<{ received: boolean }> { const rawBody = req.rawBody; if (!rawBody) { throw new BadRequestException('Raw body unavailable; enable rawBody in NestFactory.create'); } const webhookSecret = this.configService.get('SENT_DM_WEBHOOK_SECRET'); if (!webhookSecret) { // Fail closed: never process an event you cannot verify throw new UnauthorizedException('Webhook secret not configured'); } if (!this.verifySignature(webhookId, timestamp, rawBody, signature, webhookSecret)) { throw new UnauthorizedException('Invalid webhook signature'); } // Reject replayed events older than 5 minutes if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { throw new UnauthorizedException('Webhook timestamp too old'); } const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent; await this.handleEvent(event); return { received: true }; } // The HMAC key is the signing secret after stripping the "whsec_" prefix // and base64-decoding the remainder. private verifySignature( webhookId: string, timestamp: string, rawBody: Buffer, signature: string, secret: string, ): boolean { if (!webhookId || !timestamp || !signature) { return false; } const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signedContent = `${webhookId}.${timestamp}.${rawBody.toString('utf8')}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signedContent).digest('base64'); const signatureBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); return signatureBuffer.length === expectedBuffer.length && timingSafeEqual(signatureBuffer, expectedBuffer); } private async handleEvent(event: WebhookEvent): Promise { if (event.field !== 'message') { this.logger.log(`Unhandled webhook field: ${event.field}`); return; } switch (event.event) { case 'message.delivered': this.logger.log(`Message ${event.payload.message_id} delivered`); break; case 'message.failed': this.logger.error(`Message ${event.payload.message_id} failed (status ${event.payload.message_status})`); break; case 'message.received': this.logger.log(`Inbound message from ${event.payload.inbound_number}: ${event.payload.text}`); break; default: this.logger.log(`Message ${event.payload.message_id} status: ${event.payload.message_status}`); } } } ``` Register the controller in a module, import it in `AppModule`, then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "NestJS integration", "endpoint_url": "https://your-domain.example/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash npm run start:dev ``` Send a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:3000/api/messages/send \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155551234", "templateName": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `messageId` and `"status": "QUEUED"`. A 400 here means the request shape is wrong; sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your Nest log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you validate request bodies with `class-validator`, turn `SendMessageDto` into a decorated class (the appendix below has the schema). - If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and hand off to a queue such as BullMQ so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - If you package the client for reuse across apps, wrap the provider in a `DynamicModule` with `forRootAsync` options instead of the global module shown here. - To send free-form text instead of a template, pass `text` instead of `template`. Each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production NestJS stack. Adapt them to your own conventions rather than adopting them wholesale. Reject malformed requests before they reach the SDK: ```typescript // messages/dto/send-message.dto.ts import { IsArray, IsBoolean, IsIn, IsObject, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class SendMessageDto { @Matches(/^\+[1-9]\d{1,14}$/, { message: 'phoneNumber must be E.164 format' }) phoneNumber: string; @IsString() @MaxLength(100) templateName: string; @IsOptional() @IsObject() parameters?: Record; @IsOptional() @IsArray() @IsIn(['sms', 'whatsapp', 'rcs'], { each: true }) channels?: string[]; @IsOptional() @IsBoolean() sandbox?: boolean; } ``` Enable it globally with `app.useGlobalPipes(new ValidationPipe({ whitelist: true }))`. Map SDK errors to consistent HTTP responses instead of leaking stack traces: ```typescript // common/filters/sent-exception.filter.ts import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common'; import SentDm from '@sentdm/sentdm'; import type { Response } from 'express'; @Catch(SentDm.APIError) export class SentExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(SentExceptionFilter.name); catch(exception: InstanceType, host: ArgumentsHost) { const response: Response = host.switchToHttp().getResponse(); const status = exception.status ?? HttpStatus.INTERNAL_SERVER_ERROR; this.logger.error(`Sent API error (${status}): ${exception.message}`); response.status(status >= 500 ? 502 : status).json({ error: { code: exception.constructor.name, message: exception.message }, }); } } ``` Register it in `main.ts` with `app.useGlobalFilters(new SentExceptionFilter())`. Inject a mock in place of `SENT_CLIENT`; the [SDK testing guide](/sdks/testing) covers the wider strategy: ```typescript // messages/messages.service.spec.ts const mockClient = { messages: { send: jest.fn().mockResolvedValue({ data: { status: 'QUEUED', recipients: [{ message_id: 'msg_123' }] }, }), }, }; const module = await Test.createTestingModule({ providers: [MessagesService, { provide: SENT_CLIENT, useValue: mockClient }], }).compile(); ``` ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [TypeScript SDK reference](/sdks/typescript) for retries, timeouts, and error types - New to Sent? The [first integration tutorial](/sdks/typescript/first-integration) walks the same loop from scratch ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/integrations/nextjs.txt TITLE: Sending messages from Next.js with the Sent TypeScript SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/integrations/nextjs.txt Wire the Sent TypeScript SDK into a Next.js app: install, configure a server client, send from a route handler, verify webhooks, and test sandbox sends. # Sending messages from Next.js with the Sent TypeScript SDK This guide shows you how to wire Sent messaging into an existing Next.js app: install the TypeScript SDK, configure a server-side client, send a template message from a route handler, receive delivery webhooks, and verify the whole loop in sandbox mode. ## Prerequisites This guide assumes a working Next.js 14+ app using the App Router. You also need: - A Sent API key from the [API Keys page in your Sent Dashboard](https://app.sent.dm/dashboard/api-keys) - A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the [webhook local development guide](/start/webhooks/local-development) ### Install the SDK Add the SDK to your existing project: ```bash npm install @sentdm/sentdm ``` ### Configure a server-side client Add the credentials to `.env.local`. Do not prefix them with `NEXT_PUBLIC_`; the API key must never reach the browser: ```bash # .env.local SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secret ``` Create one shared client module for all server code (route handlers, Server Actions, Server Components): ```typescript // lib/sent/client.ts import SentDm from '@sentdm/sentdm'; const apiKey = process.env.SENT_DM_API_KEY; if (!apiKey && process.env.NODE_ENV === 'production') { throw new Error('SENT_DM_API_KEY is required in production'); } export const sentClient = new SentDm({ apiKey: apiKey || 'test-key', maxRetries: 2, timeout: 30 * 1000, }); ``` ### Send a template message from a route handler Add a route handler that calls `messages.send`; the pass-through `sandbox` flag lets callers exercise the route without delivering anything: ```typescript // app/api/messages/route.ts import { NextRequest, NextResponse } from 'next/server'; import { sentClient } from '@/lib/sent/client'; export async function POST(request: NextRequest) { const { phoneNumber, templateName, parameters, channels, sandbox } = await request.json(); const response = await sentClient.messages.send({ to: [phoneNumber], // E.164 format, for example +14155551234 template: { name: templateName, parameters }, // reference by name or id, never both channel: channels, // omit to let Sent pick per recipient sandbox: sandbox ?? false, // true = validate and simulate only }); const recipient = response.data.recipients[0]; return NextResponse.json( { messageId: recipient.message_id, status: response.data.status }, { status: 202 }, ); } ``` Sent accepts sends asynchronously: the API responds with status `QUEUED` and one `message_id` per recipient-and-channel pair. Store the `message_id`, because delivery outcomes arrive on your webhook endpoint instead of in this response. Client Components must call this route (or a Server Action); they can never hold the SDK client. ### Receive delivery webhooks Add a webhook route handler that verifies the `x-webhook-signature` header against the raw body before trusting any event; `request.text()` gives you the exact bytes. The scheme is HMAC-SHA256 over `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}`, keyed with the base64-decoded secret after stripping its `whsec_` prefix. Refer to [webhook signature verification](/start/webhooks/signature-verification) for the full scheme. Every event arrives in the same envelope (`field`, `event`, `timestamp`, `payload`), so one handler routes all of them: ```typescript // app/api/webhooks/sent/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createHmac, timingSafeEqual } from 'crypto'; const WEBHOOK_SECRET = process.env.SENT_DM_WEBHOOK_SECRET; // The HMAC key is the signing secret after stripping the "whsec_" prefix // and base64-decoding the remainder; signature format is "v1,{base64(hmac)}" function verifySignature(webhookId: string, timestamp: string, rawBody: string, signature: string, secret: string): boolean { const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signedContent = `${webhookId}.${timestamp}.${rawBody}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signedContent).digest('base64'); const signatureBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); return signatureBuffer.length === expectedBuffer.length && timingSafeEqual(signatureBuffer, expectedBuffer); } export async function POST(request: NextRequest) { const webhookId = request.headers.get('x-webhook-id'); const timestamp = request.headers.get('x-webhook-timestamp'); const signature = request.headers.get('x-webhook-signature'); if (!webhookId || !timestamp || !signature) { return NextResponse.json({ error: 'Missing webhook headers' }, { status: 401 }); } if (!WEBHOOK_SECRET) { // Fail closed: never process an event you cannot verify return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 }); } const rawBody = await request.text(); if (!verifySignature(webhookId, timestamp, rawBody, signature, WEBHOOK_SECRET)) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); } // Reject replayed events older than 5 minutes if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return NextResponse.json({ error: 'Timestamp too old' }, { status: 401 }); } const event = JSON.parse(rawBody); const payload = event.payload ?? {}; if (event.field === 'message') { switch (event.event) { case 'message.delivered': console.log(`Message ${payload.message_id} delivered`); break; case 'message.failed': console.error(`Message ${payload.message_id} failed (status ${payload.message_status})`); break; case 'message.received': console.log(`Inbound ${payload.channel} from ${payload.inbound_number}: ${payload.text}`); break; default: console.log(`Message ${payload.message_id} status: ${payload.message_status}`); } } return NextResponse.json({ received: true }); } ``` Keep this route on the default Node.js runtime, because it uses the `crypto` module. Then tell Sent where to deliver events. If you prefer a UI, use the [webhooks getting started guide](/start/webhooks/getting-started); otherwise register over the API: ```bash curl -X POST https://api.sent.dm/v3/webhooks \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Next.js integration", "endpoint_url": "https://your-domain.example/api/webhooks/sent", "event_types": ["message"] }' ``` Copy two values from the response: the webhook `id` (used to test delivery in the next step) and `signing_secret`. Put the secret in `SENT_DM_WEBHOOK_SECRET`. The `message` event type covers every message event; the [webhook event types reference](/start/webhooks/event-types) lists all payload fields. ### Verify the integration Start the app with your credentials loaded: ```bash npm run dev ``` Send a sandbox message through your new route. Full validation runs, but nothing is delivered and no credits are consumed: ```bash curl -X POST http://localhost:3000/api/messages \ -H "Content-Type: application/json" \ -d '{"phoneNumber": "+14155551234", "templateName": "welcome", "parameters": {"name": "Ada"}, "sandbox": true}' ``` The response should contain a `messageId` and `"status": "QUEUED"`. A 400 here means the request shape is wrong; sandbox requests return real validation errors. Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook `id` you copied: ```bash curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \ -H "x-api-key: $SENT_DM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.delivered"}' ``` Your dev server log should show a `Message ... delivered` line, and the endpoint should have answered 200 `{"received": true}`. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix. ## Adapt this to your app - If you send from a form, use a Server Action instead of the route handler. The appendix below shows the pattern; the SDK call is identical. - If you deploy on the Edge runtime, create the client inside the edge route with lower `timeout` and `maxRetries` values, and keep the webhook route on Node.js. - If webhook processing does slow work (database writes, downstream calls), return 200 first and hand off to a queue so retries do not pile up; see [handling webhook retries](/start/webhooks/handling-retries). - To send free-form text instead of a template, pass `text` instead of `template`. Each send carries exactly one of the two. ## Appendix: production scaffolding The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Next.js stack. Adapt them to your own conventions rather than adopting them wholesale. Call the SDK directly from a Server Action when the sender is a form in your own app: ```typescript // app/actions/messages.ts 'use server'; import { sentClient } from '@/lib/sent/client'; export async function sendMessage(formData: FormData) { const response = await sentClient.messages.send({ to: [formData.get('phoneNumber') as string], template: { name: formData.get('templateName') as string, parameters: JSON.parse((formData.get('parameters') as string) || '{}'), }, sandbox: formData.get('sandbox') === 'true', }); const recipient = response.data.recipients[0]; return { messageId: recipient.message_id, status: response.data.status }; } ``` Reject malformed requests before they reach the SDK: ```typescript // lib/sent/schemas.ts import { z } from 'zod'; export const sendMessageSchema = z.object({ phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Phone number must be in E.164 format'), templateName: z.string().min(1), parameters: z.record(z.string()).optional(), channels: z.array(z.enum(['sms', 'whatsapp', 'rcs'])).optional(), sandbox: z.boolean().optional(), }); ``` In the route handler, replace the destructuring with `sendMessageSchema.safeParse(await request.json())` and return 400 with `validated.error.errors` when parsing fails. Convert SDK errors into consistent JSON responses: ```typescript // lib/sent/errors.ts import SentDm from '@sentdm/sentdm'; export function handleSentError(error: unknown) { if (error instanceof SentDm.APIError) { return { message: error.message, status: error.status ?? 500, code: error.constructor.name }; } return { message: 'An unexpected error occurred', status: 500, code: 'UnknownError' }; } ``` Wrap route handler bodies in `try/catch` and pass failures through this helper. The [SDK testing guide](/sdks/testing) covers mocking `messages.send` in route tests. ## Next steps - Review the [webhook event types reference](/start/webhooks/event-types) for every payload field - Work through the [webhook production checklist](/start/webhooks/production-checklist) before going live - Explore the [TypeScript SDK reference](/sdks/typescript) for retries, timeouts, and error types - New to Sent? The [first integration tutorial](/sdks/typescript/first-integration) walks the same loop from scratch ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/10dlc-campaigns-api.txt TITLE: Register 10DLC Campaigns via the API ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/10dlc-campaigns-api.txt Create, track, update, and delete 10DLC campaigns with the Sent API, from SENT_CREATED to ACTIVE, plus bulk registration patterns for agencies and resellers. # Register 10DLC Campaigns via the API This guide shows you how to register and track 10DLC campaigns programmatically with the `/v3/profiles/{profileId}/campaigns` endpoints. Use it when you manage US SMS registration for many brands at once, the typical setup for agencies and resellers, or when you want campaign submission wired into your own onboarding flow instead of the dashboard. It assumes you already understand 10DLC and have a registered brand. If you are new to 10DLC, or you want the dashboard flow, start with the [10DLC Registration Guide](/start/advanced/10dlc-registration). That guide also owns the content rules your campaign must satisfy: opt-in forms, autoresponses, and sample messages. This page covers only the API mechanics. ## Prerequisites - An organization with at least one [Sender Profile](/start/concepts/sender-profiles). The `{profileId}` in every campaign endpoint must be a profile that belongs to your organization. - A brand for the profile, either registered for the profile itself or inherited from the organization. Without one, campaign requests return `404 RESOURCE_009` ("Brand not found for this profile"). - Your organization API key, passed in the `x-api-key` header. ## Register a Campaign ### Give the Profile Its Own Campaign A profile whose TCR campaign is inherited from the organization (`inherit_tcr_campaign: true`) has read-only campaigns; create and update requests return `400 VALIDATION_001`. To register a dedicated campaign for the profile, turn inheritance off first: ```bash curl -X PATCH "https://api.sent.dm/v3/profiles/$PROFILE_ID" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"inherit_tcr_campaign": false}' ``` The brand can stay inherited: when `inherit_tcr_brand` is `true`, the campaign you create attaches to the organization's brand. This partial inheritance (shared brand, dedicated campaign) is a supported [sender profile TCR pattern](/start/concepts/sender-profiles#tcr-brand-and-campaign). ### Create the Campaign Send `POST /v3/profiles/{profileId}/campaigns` with at least one use case. Each use case carries 1 to 5 sample messages of up to 1,024 characters each. ```bash curl -X POST "https://api.sent.dm/v3/profiles/$PROFILE_ID/campaigns" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 10dlc-reg-$PROFILE_ID" \ -d '{ "campaign": { "name": "Customer Notifications", "description": "Appointment reminders and account notifications", "type": "App", "volume": "1500", "useCases": [ { "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ] } ], "messageFlow": "User signs up on website and opts in to receive SMS notifications", "privacyPolicyLink": "https://acmecorp.com/privacy", "termsAndConditionsLink": "https://acmecorp.com/terms", "optinMessage": "You have opted in to Acme Corp notifications. Reply STOP to opt out.", "optoutMessage": "You have been unsubscribed. Reply START to opt back in.", "helpMessage": "Reply STOP to unsubscribe or contact support@acmecorp.com", "optinKeywords": "YES, START, SUBSCRIBE", "optoutKeywords": "STOP, UNSUBSCRIBE, END", "helpKeywords": "HELP, INFO, SUPPORT" } }' ``` `name`, `description`, `type`, and `useCases` are always required. When the brand is a TCR application, the compliance fields are required too: `messageFlow`, `privacyPolicyLink`, `termsAndConditionsLink`, all three autoresponse messages, and all three keyword lists. Requests missing any of them return `400 VALIDATION_001` naming the missing field. Write these values to pass carrier review, not just validation; the [opt-in form requirements](/start/advanced/10dlc-registration#opt-in-form-requirements) and [autoresponse requirements](/start/advanced/10dlc-registration#autoresponse-requirements) list what reviewers reject. The `volume` field sets your expected daily messaging volume for this campaign as a numeric string (for example `"1500"` or `"10000"`). It is optional; if omitted on create, `volume` is `null` in the response and no explicit tier is declared to TCR. On update, omitting `volume` preserves the existing value. It drives two things when set: - **TCR tier**: values strictly below `2000` register the campaign at the low-volume tier (capped at 2,000 messages per day, lower monthly TCR fee); `2000` and above register as standard (higher throughput, higher fee). - **Billing**: if an update crosses the tier boundary, the billing delta for the current window is applied immediately — the difference is not deferred to the next billing sweep. Sent passes `volume` to TCR as part of the campaign submission and surfaces it back in the response. Messaging volume is a per-campaign setting; it is not set on the brand. The `Idempotency-Key` header makes retries safe: the API caches the response for 24 hours per key per customer, so a retried request cannot create a duplicate campaign. Refer to [Idempotency](/reference/api/idempotency) for key rules. To validate a payload without registering anything, add `"sandbox": true` next to `"campaign"` in the request body. The API runs authentication and validation, then returns a simulated `201` with no side effects. See [Sandbox Mode](/reference/api/test-mode). ### Verify the Campaign Was Created A successful create returns `201` with the campaign in `data`. Every new campaign starts in `SENT_CREATED` status with `submittedToTCR: false`: ```json { "success": true, "data": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Customer Notifications", "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "SENT_CREATED", "submittedToTCR": false, "tcrCampaignId": null, "volume": "1500", "useCases": [ { "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": ["..."] } ] } } ``` (Response abbreviated.) Store `data.id`; you need it to update or delete the campaign later. To confirm the campaign from another process, list the profile's campaigns: ```bash curl "https://api.sent.dm/v3/profiles/$PROFILE_ID/campaigns" \ -H "x-api-key: $SENT_API_KEY" ``` For a profile with `inherit_tcr_campaign: true`, this returns the organization's inherited campaigns instead. ## Campaign Status Lifecycle Poll `GET /v3/profiles/{profileId}/campaigns` and read the `status` field to track each campaign: | Status | Meaning | What to do | |---|---|---| | `SENT_CREATED` | The campaign exists in Sent but TCR has not approved it. Every campaign starts here. | Wait for review and submission; see below. | | `ACTIVE` | TCR approved the campaign. Your US SMS traffic is registered. | Nothing; start sending. | | `EXPIRED` | TCR deactivated the campaign. Sent releases the campaign's dedicated number back to the pool. | Register a new campaign, or contact [support@sent.dm](mailto:support@sent.dm) to investigate. | ### While the Campaign Is in SENT_CREATED `SENT_CREATED` covers two waiting stages, distinguishable by `submittedToTCR`: 1. **Not yet submitted** (`submittedToTCR: false`): the Sent compliance team reviews your campaign content before submitting it to TCR on your behalf, the same [review flow](/start/advanced/10dlc-registration#applying-through-sent) as dashboard submissions. 2. **Submitted, awaiting TCR** (`submittedToTCR: true`, `tcrCampaignId` set): TCR and the carriers are reviewing. Most submissions come back approved within 1 to 3 business days. When TCR reports a decision, Sent updates `status` automatically. If a campaign sits in `SENT_CREATED` well past that window: - Confirm every compliance field is filled in and meets the [opt-in form requirements](/start/advanced/10dlc-registration#opt-in-form-requirements); incomplete content stalls review. - Confirm the brand itself finished verification; campaigns cannot be approved under an unverified brand. - Contact [support@sent.dm](mailto:support@sent.dm) with the campaign `id`. If TCR rejected the campaign, the compliance team tells you exactly what to fix before resubmission. ## US Messaging Use Case Values `messagingUseCaseUs` accepts one of 13 values. Pick what matches your actual traffic; carriers flag mismatches between the registered use case and the messages you send. The [campaign type guidance](/start/advanced/10dlc-registration#choosing-the-right-campaign-type) explains how to choose. | Value | Typical traffic | |---|---| | `MARKETING` | Promotional offers, discounts, announcements | | `ACCOUNT_NOTIFICATION` | Password resets, balance alerts, appointment reminders | | `CUSTOMER_CARE` | Support conversations, ticket updates | | `FRAUD_ALERT` | Suspicious-activity notices | | `TWO_FA` | One-time passcodes, login verification | | `DELIVERY_NOTIFICATION` | Order shipped, out for delivery | | `SECURITY_ALERT` | Security incident notifications | | `M2M` | Machine-to-machine traffic | | `MIXED` | Multiple use cases under one campaign | | `HIGHER_EDUCATION` | Messaging from colleges and universities | | `POLLING_VOTING` | Polling and voting notifications | | `PUBLIC_SERVICE_ANNOUNCEMENT` | Public service announcements | | `LOW_VOLUME` | Mixed use cases, capped at 2,000 messages per day | The `LOW_VOLUME` use-case type and the `volume` field are independent. `LOW_VOLUME` declares what kind of traffic you send; `volume` declares your expected daily message count and determines the TCR fee tier. A campaign can use the `LOW_VOLUME` use-case type while setting `volume` to any value, and vice versa. ## Update or Delete a Campaign To fix campaign content, for example after a rejection or to refresh sample messages, send the full campaign object to `PUT /v3/profiles/{profileId}/campaigns/{campaignId}`. The required fields match create: `name`, `description`, `type`, and `useCases`. The use cases you send replace the existing list; optional fields you omit keep their current values. If you are migrating from the old `expected_messaging_volume` field on the brand, set `volume` here instead. See the [July 2026 changelog](/reference/changelog#10dlc-messaging-volume-moved-to-campaign-breaking-change) for the full migration table. If the new value crosses the `2000` tier boundary, the billing delta for the current window is applied immediately. ```bash curl -X PUT "https://api.sent.dm/v3/profiles/$PROFILE_ID/campaigns/$CAMPAIGN_ID" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "campaign": { "name": "Customer Notifications", "description": "Appointment reminders and account notifications", "type": "App", "volume": "1500", "useCases": [ { "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Acme Corp: Your appointment is confirmed for {date} at {time}." ] } ] } }' ``` If the campaign was already submitted to TCR, Sent syncs the update to TCR. Check `tcrSyncError` in the response: `null` means the sync succeeded; otherwise it holds the TCR error message and the change is saved in Sent only. To remove a campaign, send `DELETE /v3/profiles/{profileId}/campaigns/{campaignId}`. A successful delete returns `204` with no body. Deleting a campaign is permanent. To change a campaign's content, update it instead of deleting and recreating it, so you keep its review progress. ## Bulk Registration for Agencies and Resellers TCR ties each campaign to the business responsible for the messages, so every client needs its own registration; you cannot bundle clients under one campaign. The API pattern: 1. Map each client to a Sender Profile with a dedicated TCR campaign (and a dedicated or inherited brand, per client). The [multi-tenant patterns](/start/concepts/sender-profiles#architectural-patterns) page compares the options. 2. Enumerate profiles with `GET /v3/profiles` and create one campaign per profile, as shown earlier, collecting the client's compliance details in your own onboarding form. 3. Poll each profile's campaign list, or check it on demand, and surface `status` to the client until it reads `ACTIVE`. Two scoping notes for organization keys: - The campaign endpoints take the profile ID in the URL path, so you do not need the `x-profile-id` header here. Your organization API key can address every profile in the organization directly. - Where you do use `x-profile-id` (endpoints without a profile ID in the path, such as sending messages), only organization API keys may send it; profile-scoped keys receive `403`. Use a distinct `Idempotency-Key` per profile (for example, derived from your client ID) so a retried batch never double-registers a client. ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `400 VALIDATION_001`: "Cannot create campaigns when inherit_tcr_campaign=true" (or "These campaigns are read-only") | The profile inherits the organization's campaign | `PATCH /v3/profiles/{profileId}` with `{"inherit_tcr_campaign": false}` | | `400 VALIDATION_001`: "MessageFlow is required for TCR applications" (or similar) | The brand is a TCR application and a compliance field is missing | Include `messageFlow`, both policy links, and all autoresponse messages and keywords | | `404 RESOURCE_009`: "Brand not found for this profile" | No brand is registered for the profile or its organization | Register the brand first; see the [10DLC Registration Guide](/start/advanced/10dlc-registration) | | `404 RESOURCE_014`: "Profile not found" | `{profileId}` is not a child profile of your organization (passing your organization's own ID also fails) | Use a profile ID returned by `GET /v3/profiles` | | `404 RESOURCE_010`: "Campaign not found for this profile" | `{campaignId}` does not belong to this profile's brand | List the profile's campaigns and reuse the returned `id` | | Campaign stays in `SENT_CREATED` | Review or TCR submission still pending | Work through the checks in [Campaign Status Lifecycle](#campaign-status-lifecycle) | Error codes and remediation for the whole API are in the [Error Catalog](/reference/api/error-catalog). ## Related Pages - Endpoint reference: [create campaign](/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint), [list campaigns](/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint), [update campaign](/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint), [delete campaign](/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint) - [10DLC Registration Guide](/start/advanced/10dlc-registration) for the registration concepts, dashboard flow, and content requirements - [Sender Profiles](/start/concepts/sender-profiles) for the inheritance model behind `inherit_tcr_brand` and `inherit_tcr_campaign` - [Idempotency](/reference/api/idempotency) and [Sandbox Mode](/reference/api/test-mode) for safe retries and dry runs ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/10dlc-registration.txt TITLE: 10DLC Registration Guide ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/10dlc-registration.txt How to register your brand and messaging campaigns with TCR through the Sent dashboard, including the form inputs, opt-in rules, and autoresponses required # 10DLC Registration Guide **TL;DR:** To send SMS to US numbers, you need to register your business and messaging use cases. Sent handles the submission on your behalf. You fill out the form in our platform, our compliance team reviews it, and we submit to TCR. Most approvals take 1–3 business days. This guide shows you how to register for 10DLC through the Sent dashboard: where to start, which campaign type to pick, and what to put in each part of the form so it passes review. If you are new to 10DLC, or you want to understand why US carriers require registration and how the review works, read [What is 10DLC?](/start/concepts/10dlc) first. To register many brands programmatically, see [Register 10DLC Campaigns via the API](/start/advanced/10dlc-campaigns-api). ![10DLC registration process overview](/10dlc-process.svg) ## Applying through Sent ### Fill out the form In the Sent dashboard, go to **Compliance** and select **Apply** on the US 10DLC notice, or open the [10DLC registration form](https://app.sent.dm/dashboard/brand-registration) directly. Submit your business and campaign details; the form collects everything TCR requires, and the sections below cover what to enter in each part. ### Compliance review Our compliance team reviews your submission and gives you feedback to maximize your chances of approval before it reaches TCR. ### TCR submission We submit your registration to TCR on your behalf. ### Approval tracking We track the review and notify you when you're approved, or if anything needs fixing. Most submissions come back approved within **1–3 business days**. If your submission is rejected, the Sent compliance team reviews it and gives you specific steps for resubmission. All Sent customers (including those who haven't sent any messages yet) get compliance support throughout this process. --- ## Choosing the right campaign type Pick the use cases that reflect what you're actually sending. Don't try to game this: carriers flag mismatches between your registered use case and your actual messages. | Use case | When to use | |---|---| | Marketing | Promotional offers, discounts, announcements | | Customer care | Support conversations, ticket updates | | 2FA / OTP | One-time passcodes, login verification | | Account notifications | Password resets, balance alerts | | Appointment reminders | Booking confirmations, schedule changes | | Delivery notifications | Order shipped, out for delivery | | Fraud alerts | Suspicious activity notices | If you have multiple use cases, a **mixed-use campaign** covers them all under one brand. For smaller senders, **low-volume mixed** offers the same flexibility with a cap of 2,000 messages per day. This is usually what Sent recommends for growing businesses and startups. ### TCR fees TCR charges pass-through fees for campaign registration. Sent waives the first 12 months of these fees. | Campaign type | TCR fee | |---|---| | Low-volume mixed | $1.50/month | | Standard / high-volume | $10.00/month | **These are TCR's fees, not Sent's.** Sent charges its own campaign fee on top of them, and the 12-month waiver covers only the TCR pass-through fees. Sent's fee depends on the volume tier you pick and is billed on its own schedule, which is not always monthly. You see the exact amount and billing period for your tier in the dashboard when you register the campaign, before you confirm. Check there rather than budgeting from this table. --- ## What goes into your submission ### Brand name **Brand name matters.** This field should not be your official legal name. It should be how your customers actually know you. If your LLC is registered as "Kevin's Donuts LLC" but your website and receipts say "Kevin's Donuts," use the latter. That's what appears in messages to your customers and needs to match what they'd recognize. ### Campaign description Keep this short: one or two sentences covering who's sending, what they're sending, and why. ### Opt-in method You need to document how you get consent from people before texting them. TCR calls this your **message flow**. Someone fills out a form on your site. You provide the URL and describe how they get there. The form itself has its own requirements (see below). Gathered over the phone or in person. Document the script used, where the contact info is published, and whether it's phone or in-person. Marketing use cases require double opt-in if consent is spoken. You publish a number and keyword, and subscribers text it to opt in. Best practice is to display the full disclaimer wherever you publish the number. A physical form collected during an in-person visit, with a hosted screenshot submitted as documentation. ### Opt-in form requirements If your 10DLC keeps getting rejected, the form is usually why. Carriers are strict about explicit consent: missing even one element is enough to fail. If you're using a web or paper form, it needs **all of the following**, clearly visible before the user hits submit (not buried in fine print): - **Program name**: make it clear who is texting them and what the program is - **Message frequency**: something concrete, like "Max 4 msgs/month," not "frequency may vary" - **Cost warning**: "Msg & data rates may apply" - **Opt-out info**: "Reply STOP to opt out" or "Text STOP to cancel" - **Legal links**: direct links to your Privacy Policy and Terms of Service - **Full disclaimer** (or equivalent): > *You are opting in to receive [message types] from [Brand]. Msg and data rates may apply. Msg frequency may vary. Reply HELP for help or STOP to opt out. No mobile information will be sold or shared with third parties for promotional or marketing purposes.* - SMS consent is voluntary, distinct, and explicit, typically an unchecked optional checkbox - The checkbox **cannot be pre-checked** For marketing campaigns, the language should make clear the messages are promotional. For charity or political campaigns, note whether donations will be solicited. --- ## Autoresponse requirements Every 10DLC campaign needs three documented autoresponses. Each has required elements. Triggered by START or a related keyword. Must include: - Brand name - Reply HELP for help - Reply STOP to opt out - Msg and data rates may apply - Msg frequency may vary **Example:** ``` [Brand]: Welcome! Please reply HELP for help. Message frequency may vary. Msg & data rates may apply. Reply STOP to opt out. ``` Triggered by STOP or related keywords. Must include: - Brand name - Unsubscription confirmation - Notice that no more messages will be sent **Example:** ``` [Brand]: You have been unsubscribed and will not receive more messages. ``` Triggered by HELP or related keywords. Must include: - Brand name - An email, toll-free number, or URL where support can be reached **Example:** ``` [Brand]: Please contact us at [email / toll-free number / URL] for support. ``` --- ## Sample messages For each use case you select, submit at least one sample message. Mixed and marketing use cases require two. Every sample must include the brand name and look like a real message. | Use case | Example | |---|---| | Marketing | `[Brand]: Thanks for opting in! Use code DISCOUNT20 for 20% off through the end of the month.` | | Account notification | `[Brand]: Your password has been reset.` | | 2FA | `[Brand]: Your code is 123456.` | | Customer care | `[Brand]: Thanks for your message! We'll be right with you.` | | Delivery notification | `[Brand]: Your order has been delivered.` | | Fraud alert | `[Brand]: We noticed a suspicious transaction. Log in or call us to complete it.` | You can update sample messages later. What you **cannot** do later is send messages that fall outside your registered use cases. --- ## Agencies and resellers If you're sending on behalf of clients, each business needs its own campaign registration. You can't bundle clients under a single campaign: TCR ties campaigns to the business actually responsible for the messages. You can submit registrations manually for each client through the dashboard. If you're managing multiple brands, collect the required information within your platform and submit programmatically; [Register 10DLC Campaigns via the API](/start/advanced/10dlc-campaigns-api) covers the endpoints and bulk registration patterns. --- ## If you get stuck 10DLC registration is a one-time requirement for each brand you send from. If a form field is unclear or your submission is rejected and the resubmission steps don't resolve it, email [support@sent.dm](mailto:support@sent.dm); the support team typically responds within 1 business day. For errors that appear after approval, see [Compliance & 10DLC Issues](/troubleshooting/compliance). --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/compliance-regulations.txt TITLE: Compliance & Regulations ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/compliance-regulations.txt The compliance controls Sent enforces automatically at send time, what remains your responsibility, and per-region regulations with authoritative sources. # Compliance & Regulations This page describes the compliance controls Sent enforces automatically at send time, the obligations that remain yours, and the primary messaging regulation for each region, with links to authoritative sources. For implementation code, see [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent). This page is an orientation, not legal advice. The linked regulation texts and regulator guidance govern; consult your legal counsel before launching campaigns in a new market. ## Enforced at Send Time Sent applies these controls to every outbound message. They run inside the message pipeline, after the API accepts the send with `202`; outcomes surface as message statuses and webhooks, never as synchronous HTTP errors. | Control | Behavior | Client-visible signal | |---------|----------|-----------------------| | Consent gate | Sends to opted-out or suppressed recipients are filtered before any provider call | `FILTERED` status, reason code `ERR_CONSENT_BLOCKED`, `message.filtered` webhook | | Opt-out keywords | An inbound opt-out keyword sets the contact's `opt_out` flag across all channels | `message.received` webhook, `opt_out` on the contact record | | Quiet hours | Sends inside a protected local-time window are held, then released automatically | `SCHEDULED` status, `message.scheduled` webhook | | RCS STOP chip | Every outbound RCS message carries a STOP suggested-reply chip | Visible on the delivered RCS message | ### Consent Gate Sent enforces a consent gate on every message. The gate runs inside the message pipeline, after the API accepts your send: if the recipient's contact has `opt_out = true`, or their phone is on your phone-channel suppression list, the message is finalized as `FILTERED` instead of being dispatched. No provider call is made and you are not charged. The block is recorded with reason code `ERR_CONSENT_BLOCKED` (see the [Error Catalog](/reference/api/error-catalog)); the client-visible signal is the `FILTERED` status. The gate is fully asynchronous. A `POST /v3/messages` batch is accepted with `202` like any other send, even when **every** recipient has opted out; there is no synchronous `BUSINESS_004` rejection. Each message is then finalized as `FILTERED` individually, so detect opt-out blocks from message statuses (`message.filtered` webhooks or `GET /v3/messages/{id}`), not from the HTTP response. Compliance auto-replies are exempt from the gate: the confirmations Sent sends in response to STOP, START, and HELP keywords are delivered even to opted-out contacts, because CTIA and TCPA guidance permits (and for STOP and HELP requires) that one reply. ### Opt-Out Keywords Every account is seeded with the CTIA/TCPA-mandated default keywords. Matching is exact and case-insensitive: the entire trimmed message body must equal the keyword. | Action | Default keywords | Effect | |--------|------------------|--------| | Opt out | `STOP`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END` | Sets `opt_out = true` on the contact | | Opt in | `START`, `UNSTOP`, `SUBSCRIBE` | Sets `opt_out = false` on the contact | | Help | `HELP`, `INFO` | Triggers the configured HELP auto-reply | Opt-out is contact-level and channel-agnostic: a `STOP` received on any channel suppresses the contact on SMS, WhatsApp, and RCS alike. Custom keywords are configured in the dashboard under **Compliance → Opt-Out Keywords**. Keyword mechanics, auto-replies, and per-channel caveats are covered in [Two-Way Conversations](/start/guides/two-way-conversations). ### Quiet Hours Quiet-hours rules are defined per destination country and can be scoped to specific channels and template categories. A send that lands inside a protected window is not failed: the message is held with status `SCHEDULED` and released automatically when the window opens. No action is required to release it. - The recipient's country and local time zones are derived from the phone number. - When a number spans multiple time zones, the message is held if any zone is inside a window and released only once the window has opened in all of them. - Held messages report status `SCHEDULED` on `GET /v3/messages/{id}` and fire a `message.scheduled` webhook. ### RCS STOP Chip Every outbound RCS message includes a `STOP` suggested-reply chip; when the template does not define one, Sent appends it automatically. Opt-out footer text is not required on RCS. A tap on the chip is processed through the same keyword pipeline as a typed `STOP`. ## Your Responsibilities Sent does not collect consent for you and does not know your marketing context. These obligations remain yours under every regulation listed below: - **Consent collection and records.** Obtain the legally required form of consent before the first message and keep proof. For US marketing texts this is prior express written consent. - **Sender registration.** US long-code traffic requires brand and campaign registration; see [10DLC Registration](/start/advanced/10dlc-registration). - **Data-subject requests.** Deletion and access requests under GDPR, LGPD, and similar laws cover the contact data you store in Sent; contacts can be removed with `DELETE /v3/contacts/{id}`. - **Stricter send windows.** Some regimes and some US states restrict marketing hours more tightly than the platform's quiet-hours rules; apply your own send window where your legal review requires one. Implementation of all four (mirroring opt-outs, setting opt-out state through the API, and custom send windows) is covered in [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent). ## Regulations by Region The summaries below orient you; the linked source governs. | Region | Regulation | Core requirements | Authoritative source | |--------|------------|-------------------|----------------------| | United States | TCPA | Prior express written consent for marketing texts; telephone solicitations restricted to 8 AM–9 PM recipient local time | [47 CFR § 64.1200 (eCFR)](https://www.ecfr.gov/current/title-47/chapter-I/subchapter-B/part-64/subpart-L/section-64.1200) | | European Union | GDPR | Lawful basis for processing (typically consent for marketing); right to erasure; records of processing | [Regulation (EU) 2016/679 (EUR-Lex)](https://eur-lex.europa.eu/eli/reg/2016/679/oj) | | Canada | CASL | Express consent before sending; sender identification and an unsubscribe mechanism in every message | [Canada's Anti-Spam Legislation (ISED)](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en) | | United Kingdom | PECR | Opt-in consent for electronic direct marketing | [Guide to PECR (ICO)](https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guide-to-pecr/) | | Australia | Spam Act 2003 | Consent, sender identification, and a functional unsubscribe facility | [Spam Act 2003 (Federal Register of Legislation)](https://www.legislation.gov.au/C2004A01214/latest/text) | | Singapore | PDPA | Consent for marketing messages; Do Not Call Registry checks | [Personal Data Protection Act (PDPC)](https://www.pdpc.gov.sg/overview-of-pdpa/the-legislation/personal-data-protection-act) | | Brazil | LGPD | Legal basis for processing personal data; data-subject rights | [ANPD (national data protection authority)](https://www.gov.br/anpd/pt-br) | ## Related Pages - [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent): mirroring opt-outs, API opt-out, custom send windows - [Two-Way Conversations](/start/guides/two-way-conversations): keyword matching, auto-replies, cross-channel opt-out - [Trust & Safety](/start/concepts/trust-and-safety): the full send-time policy pipeline - [10DLC Registration](/start/advanced/10dlc-registration): US sender registration ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced.txt TITLE: Advanced Guides Overview ================================================================================ URL: https://docs.sent.dm/llms/start/advanced.txt Advanced guides for platforms and enterprises building on Sent: performance optimization, 10DLC registration, compliance, v2 to v3 migration, and multi-tenancy. # Advanced Guides Overview Advanced guides for power users, platforms, and enterprises building mission-critical messaging systems. ## Topics These guides assume familiarity with Sent fundamentals. If you're new, start with the [Quickstart](/start/quickstart) and [Core Concepts](/start/concepts). --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/migration-v2-v3.txt TITLE: API v2 to v3 Migration ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/migration-v2-v3.txt Migrate your integration from the legacy Sent v2 API to v3 and update auth headers, request and response envelopes, error codes, webhooks, and idempotency. # API v2 to v3 Migration Migrate your integration from the legacy v2 API to the current v3 API. ## Key Differences | Feature | v2 | v3 | |---------|-----|-----| | Base URL | `api.sent.dm/v2` | `api.sent.dm/v3` | | Auth Header | `x-sender-id` + `x-api-key` | `x-api-key` only | | Request Body | `phoneNumber` or `contactId`, `templateId`, `templateVariables` | `to`, `template` (object with `id`/`name`/`parameters`), `channel` | | Response Format | Flat data | Envelope with `success`, `data`, `error`, `meta` | | Rate Limits | Varies | 200 req/min (standard), 10 req/min (sensitive) | | Sandbox Mode | Not available | `sandbox: true` | | Templates | Simple text | Structured components | | Contacts | Basic | Channel intelligence | ## Authentication Changes ### v2 (Legacy) ```http GET /v2/messages/{id} x-sender-id: your-sender-id x-api-key: your-api-key ``` ### v3 (Current) ```http GET /v3/messages/{id} x-api-key: your-api-key ``` ## Request Format Changes ### Sending Messages #### v2 (Legacy) v2 sends to one recipient per request through separate endpoints. `POST /v2/messages/phone` is shown here; `POST /v2/messages/contact` takes `contactId` instead of `phoneNumber`: ```json { "phoneNumber": "+1234567890", "templateId": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "templateVariables": { "name": "John", "order_id": "12345" } } ``` #### v3 (Current) ```json { "to": ["+1234567890"], "template": { "id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "name": "order_confirmation", "parameters": { "name": "John", "order_id": "12345" } }, "channel": ["sms"], "sandbox": false } ``` **Key Changes:** - Separate `/v2/messages/phone` and `/v2/messages/contact` endpoints → single `POST /v3/messages` - `phoneNumber` (single string) → `to` (array of recipients) - `templateId` → `template.id` (inside the `template` object) - `templateVariables` → `template.parameters` - Added `template.name` (optional) - Added `channel`: explicit per-request channel selection - Added `sandbox`: sandbox mode is new in v3; v2 has no equivalent ## Response Format Changes ### v2 (Legacy) `POST /v2/messages/phone` returns `202 Accepted` with a bare message ID: ```json { "messageId": "8ba7b830-9dad-11d1-80b4-00c04fd430c8" } ``` ### v3 (Current) ```json { "success": true, "data": { "status": "QUEUED", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+1234567890", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_abc123", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` **Key Changes:** - Response is now wrapped in an envelope with `success`, `data`, `error`, `meta` - Bare `messageId` → one entry per recipient and channel in `data.recipients[]`, each with its own `message_id` - Added `meta.request_id` to quote in support tickets ## Error Format Changes ### v2 (Legacy) v2 returns RFC 9110 problem details with human-readable text only: ```json { "type": "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "PhoneNumber": ["Phone number is required"] } } ``` ### v3 (Current) ```json { "success": false, "status": 402, "error": { "code": "BUSINESS_003", "message": "Account balance is insufficient to send this message", "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_abc123", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` **Key Changes:** - Errors now use the same envelope as success responses (`success: false`, `error`, `meta`) instead of RFC 9110 problem details - Added machine-readable `error.code` values prefixed by category (for example, `BUSINESS_003`, `AUTH_001`, `VALIDATION_002`); match on the code instead of parsing message text - Added `doc_url` with link to error documentation ## Webhook Event Changes ### v2 (Legacy) Legacy webhooks delivered a flat body identified by a `type` field with the plural value `messages`: ```json { "type": "messages", "timestamp": "2025-01-15T08:30:15Z", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "recipient_phone_number": "+1234567890", "message_status": "DELIVERED", "channel": "sms" } ``` ### v3 (Current) ```json { "field": "message", "event": "message.delivered", "timestamp": "2025-01-15T08:30:15Z", "payload": { "updated_at": "2025-01-15T08:30:15Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1234567890", "message_status": "DELIVERED", "channel": "sms" } } ``` **Key Changes:** - `type` renamed to `field`; its value changed from `messages` (plural) to `message` (singular) - Added `event`: granular sub-type (such as `message.delivered`, `message.failed`) - Added `payload` wrapper: event data is now nested under `payload` - `message_id`, `template_id`, `message_status`, and `channel` moved into `payload` - `recipient_phone_number` → `payload.outbound_number` - Added `payload.updated_at`, `payload.account_id`, and `payload.template_name` - Webhooks still subscribed to the legacy `messages` event type keep receiving all `message.*` events; see the [Events Reference](/start/webhooks/event-types) for the full catalog ## Endpoint Mapping | v2 Endpoint | v3 Endpoint | Changes | |-------------|-------------|---------| | `POST /v2/messages/contact` | `POST /v3/messages` | Single send endpoint; new request/response format | | `POST /v2/messages/phone` | `POST /v3/messages` | Single send endpoint; new request/response format | | `GET /v2/messages/{id}` | `GET /v3/messages/{id}` | Response uses envelope format | | `GET /v2/contacts` | `GET /v3/contacts` | Response uses envelope format | | Not available in v2 | `POST /v3/contacts` | New in v3: create contacts through the API | | `POST /v2/templates` | `POST /v3/templates` | New template structure | ## Idempotency Changes ### v2 (Legacy) v2 has no idempotency support: retrying a timed-out request can send the same message twice. ### v3 (Current) ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_123_456" \ -d '{ "to": ["+1234567890"], "template": {"id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8"} }' ``` **Key Changes:** - Idempotency is new in v3: send an `Idempotency-Key` header on write requests to make retries safe - Keys are cached for 24 hours ## Migration Steps 1. **Update Base URL** - Change from `/v2` to `/v3` 2. **Update Authentication** - Remove `x-sender-id` header, keep only `x-api-key` 3. **Update Request Format** - Replace `phoneNumber`/`contactId` with the `to` array, `templateId` with `template.id`, and `templateVariables` with `template.parameters` 4. **Update Response Handling** - Handle the new envelope format with `success`, `data`, `error`, `meta` 5. **Update Error Handling** - Match on the new `error.code` values (for example, `BUSINESS_003`) instead of parsing error text 6. **Update Webhook Handlers** - Adjust for new nested event structure (`payload` wrapper added; use `payload.message_id` and `payload.message_status`) 7. **Add Idempotency** - Send an `Idempotency-Key` header on write requests (new in v3) 8. **Test in Sandbox Mode** - Validate all features with `sandbox: true` 9. **Deploy Gradually** - Use feature flags for rollout The v2 API is deprecated for new integrations but remains operational, and no sunset date has been announced. New capabilities such as sandbox mode, idempotency keys, and contact creation ship in v3 only, so plan your migration now rather than against a deadline. Watch the [API changelog](/reference/changelog) for version and deprecation announcements. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/multi-tenant-architectures.txt TITLE: Multi-tenant architectures on Sent: profiles vs. accounts ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/multi-tenant-architectures.txt Choose a tenant model for your messaging platform: Sender Profiles with per-tenant provisioning, one shared Sent account, or separate accounts per tenant. # Multi-tenant architectures on Sent: profiles vs. accounts This guide shows you how to choose and set up a tenant model for a platform or ISV that sends messages on behalf of multiple customers. Sent's primary multi-tenancy mechanism is [Sender Profiles](/start/concepts/sender-profiles): isolated messaging identities inside one organization, provisioned through the API. A single shared account and fully separate accounts remain workable alternatives at the two ends of the isolation spectrum, and both are covered below with their constraints. It assumes you can already [send messages](/start/guides/sending-messages). The profile-based setup additionally requires an **organization account** ([`GET /v3/me`](/reference/api/account/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint) returns `"type": "organization"`) and an API key whose user has the `admin` role. See [roles and permissions](/reference/api/roles-and-permissions). ## Compare the three tenant models | | Sender Profiles | One shared account | Separate accounts | | --- | --- | --- | --- | | **Isolation** | Per resource: contacts, templates, TCR registration, and WhatsApp Business Account are inherited or dedicated per profile | None (every tenant shares all resources) | Complete (nothing shared) | | **Tenant provisioning** | API: [`POST /v3/profiles`](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint) + completion | None needed | Manual: each tenant signs up for their own account, and the API has no account-creation endpoint | | **Billing** | Organization, per profile, or profile with organization fallback (`billing_model`) | One bill to you | Each tenant pays Sent directly | | **Credentials** | Per-profile API key, or one organization key scoped with `x-profile-id` | One API key | One API key per tenant, managed by you | | **Rate limit pool** | Own pool per profile key; requests scoped via `x-profile-id` draw from the organization's pool | One pool shared by all tenants | Own pool per account | To pick a model: - If you onboard tenants programmatically, or tenants need isolated contact lists, their own templates, their own US A2P (TCR) registration, or their own WhatsApp presence, use **Sender Profiles**. - If every tenant sends the same kind of content under your platform's single brand and sender identity, a **shared account** is enough, provided you accept the shared rate limit pool and build tenant attribution yourself. - If tenants must own their Sent relationship end to end (their own login, their own billing relationship, no shared organization), use **separate accounts** and treat each one as an independent integration. ## Build on Sender Profiles This is the recommended model: one organization, one profile per tenant. The full provisioning walkthrough with every flag, status, and failure mode is [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api); the sequence below is the architecture-level view. ### Provision a profile per tenant Create the profile with the inheritance and billing flags that encode your isolation policy, then run the asynchronous completion step: ```bash curl -X POST "https://api.sent.dm/v3/profiles" \ -H "x-api-key: $ORG_API_KEY" \ -H "Idempotency-Key: create-profile-tenant-42" \ -H "Content-Type: application/json" \ -d '{ "name": "Tenant 42 Coffee Co", "short_name": "T42COFFEE", "description": "Sender profile for tenant 42", "inherit_contacts": false, "inherit_templates": false, "billing_model": "organization" }' ``` `inherit_contacts: false` and `inherit_templates: false` give the tenant isolated data; `billing_model: "organization"` keeps charges on your platform's bill (use `"profile"` to bill the tenant directly). Then call [`POST /v3/profiles/{profileId}/complete`](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint) with a `webHookUrl`; Sent calls it back with `COMPLETED` (ready to send) or `SUBMITTED` (pending registration or channel setup) when background provisioning finishes. Refer to the [provisioning guide](/start/advanced/sub-account-profiles-api) for the flag tables, the completion prerequisites, and troubleshooting. ### Route each tenant's traffic Two options, usable side by side: - **Per-profile API keys**: store one key per tenant and instantiate the client with it. Each profile is its own account, so each key gets its own 200 requests-per-minute [rate limit pool](/reference/api/rate-limits): one tenant's burst cannot starve another's. - **One organization key + `x-profile-id`**: keep a single credential and scope each request to a profile by UUID. All scoped requests draw from the organization's rate limit pool. ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $ORG_API_KEY" \ -H "x-profile-id: $PROFILE_ID" \ -H "Content-Type: application/json" \ -d '{"to": ["+15551234567"], "template": {"id": "tmpl_123"}}' ``` Only organization keys may send `x-profile-id`; profile keys are rejected with `403`, and a profile outside your organization returns `404`. Per-language client code for both patterns is in [Integrating Sender Profiles into Your Application](/start/guides/integrating-sender-profiles). ### Attribute webhook events to tenants Webhook events do not carry your tenant identifiers. Persist the `message_id` values from each send response against the tenant's profile, then resolve incoming events through that mapping. The receiver code is in [Track usage per profile in webhooks](/start/guides/integrating-sender-profiles#track-usage-per-profile-in-webhooks); the envelope is documented in the [events reference](/start/webhooks/event-types). Isolation is per resource, not all-or-nothing: a profile can keep dedicated contacts while inheriting your organization's template library. See the [governance model](/start/concepts/sender-profiles#resource-governance-model) for what each flag controls. ## Alternative: one shared account If all tenants message under your platform's own brand, with the same sender identity, the same templates, and no per-tenant compliance registration, you can run everything through a single account. The send request has no tenant or metadata field, so tenant attribution is entirely your app's job. Record the mapping when you send, using the `message_id` values the API returns: ```typescript const response = await client.messages.send({ to: recipients, template: { id: templateId } }); // The request body carries no tenant identifier — persist the mapping yourself await db.messages.insertMany( response.data.recipients.map((r) => ({ sentMessageId: r.message_id, to: r.to, tenantId: tenant.id })) ); ``` Webhook handlers then look up the tenant by `payload.message_id`, exactly as in the [per-profile attribution pattern](/start/guides/integrating-sender-profiles#track-usage-per-profile-in-webhooks). Constraints to plan for: - All tenants share one 200 requests-per-minute pool, so one tenant's campaign can rate-limit everyone. Enforce per-tenant quotas in your own app and pace bulk sends. See [How to handle Sent API rate limits](/start/guides/handling-rate-limits). - Every tenant sends from the same numbers, templates, and TCR registration; a compliance problem caused by one tenant affects all of them. - If you later need isolation, you can move a tenant onto a Sender Profile without leaving your organization. That migration path is the main argument for starting with profiles even when sharing would work today. ## Alternative: separate accounts per tenant If each tenant must be Sent's customer of record (their own login, their own billing, their own compliance standing, and no shared organization), give each tenant their own account. There is no API for creating accounts, so each tenant signs up through the dashboard themselves; your platform stores their API key and treats each account as an independent integration: ```typescript // One secrets-manager entry per tenant; a leaked key exposes one tenant, not all const client = new SentDm({ apiKey: await secrets.get(`sent-api-key-${tenant.id}`) }); ``` Each account has its own rate limit pool and its own bill. The costs are operational: onboarding cannot be automated, nothing (templates, contacts, registration) can be shared, and there is no organization-level view across tenants. Before choosing this model, check whether a Sender Profile with `billing_model: "profile"` and dedicated resources already gives the tenant what they actually need (direct billing and full data isolation) without giving up API provisioning. ## Verify your setup - Send a test message as one tenant (their profile key, `x-profile-id` scope, or their account key) and confirm the `202` response, then confirm the delivery events for those `message_id` values resolve to the same tenant in your database. - For profile-based setups, list templates with tenant A's key and confirm you see only that profile's own and inherited templates, never tenant B's dedicated resources. - For shared-account setups, confirm your per-tenant quota enforcement triggers before the account-wide limit does: a tenant at its quota should be throttled by your app, not by a `429` that affects every tenant. ## Related pages - [Sender Profiles](/start/concepts/sender-profiles): the inheritance and governance model behind profile-based multi-tenancy - [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api): the full provisioning walkthrough - [Integrating Sender Profiles into Your Application](/start/guides/integrating-sender-profiles): per-tenant sending and webhook attribution in eight languages - [Creating a Sender Profile](/start/guides/creating-a-sender-profile): the dashboard flow - [Rate Limits](/reference/api/rate-limits): pool scoping rules for accounts, profiles, and `x-profile-id` ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/performance-optimization.txt TITLE: Performance optimization: high throughput on the Sent API ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/performance-optimization.txt Optimize Sent API throughput: stay under the 200 requests per minute rate limit, batch up to 1,000 recipients per request, cache reads, and queue sends. # Performance optimization: high throughput on the Sent API This guide shows you how to get maximum throughput from a Sent integration: work within the documented rate limits, use batching to raise your effective ceiling, stop spending requests on avoidable reads and polling, and structure sustained volume around a queue. It assumes you can already [send messages](/start/guides/sending-messages). ## Stay under the rate limits All throughput planning starts from two numbers, both applied per customer account: | Tier | Limit | Window | Applies to | | --- | --- | --- | --- | | Standard | 200 requests per minute | Sliding 60-second window | All endpoints, including `POST /v3/messages` | | Sensitive | 10 requests per minute | Fixed 60-second window | `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test` only | Three properties of the limits shape everything else in this guide: - **The pool is shared per account.** Every API key on the account draws from the same 200 requests per minute, and requests scoped to a profile with the `x-profile-id` header count against the organization's pool. Budget the limit across your workers rather than letting each one assume it has 200. - **There is no quota readout on successful responses.** The `X-RateLimit-*` headers and `Retry-After` appear only on `429` responses, so treat each `429` as your pressure signal: honor `Retry-After` before retrying, and count `429`s in your metrics. - **Failed validation still costs budget.** Requests rejected with `400` or `422` count toward the limit (only `401`/`403` authentication rejections do not), so an oversized or malformed batch consumes a request without sending anything. Refer to the [Rate Limits reference](/reference/api/rate-limits) for the per-endpoint table and the `429` response format, and to [How to handle Sent API rate limits](/start/guides/handling-rate-limits) for ready-made backoff, monitoring, and throttling implementations in TypeScript, Python, and Go. ## Batch recipients to raise the ceiling Limits count **per request, not per recipient**, and a single `POST /v3/messages` accepts up to **1,000 recipients**. That makes batching the most effective optimization available: 200 requests per minute at 1,000 recipients each is a ceiling of 200,000 accepted messages per minute from one account, a thousand times what per-recipient requests would allow. Chunk larger lists into slices of 1,000 (a request with more recipients fails validation with `400` and still counts against the limit), and give each batch a deterministic idempotency key so retries cannot double-send: ```typescript const BATCH_SIZE = 1000; // API maximum per request for (let i = 0; i < recipients.length; i += BATCH_SIZE) { await client.messages.send( { to: recipients.slice(i, i + BATCH_SIZE), template: { id: templateId } }, { idempotencyKey: `campaign_${campaignId}_batch_${i / BATCH_SIZE}` } ); } ``` If this loop runs alongside other API traffic, pace it so combined throughput stays under 200 requests per minute. The full campaign pattern, covering pacing, partial-failure handling, and reconciling accepted messages against webhook events, is in [Batch Operations](/start/guides/batch-operations). ## Reuse one client instance Create the SDK client once, at startup or in your dependency container, and share it across requests and jobs. A shared client keeps HTTP connections alive and pooled, so high-volume sending does not pay a TLS handshake per request: ```typescript import SentDm from '@sentdm/sentdm'; // Create once at startup — not inside a request handler or per job export const sent = new SentDm({ apiKey: process.env.SENT_API_KEY }); ``` ## Cut requests with caching and webhooks The cheapest request is the one you never send, and with a shared 200-per-minute pool, every read you avoid is send capacity you keep: - **Cache repeated reads.** Contact and template lookups (`GET /v3/contacts/{id}`, `GET /v3/templates/{id}`) that your send path repeats are cache candidates. Use a short TTL and remember that message and contact state changes server-side, so expire entries when webhook events tell you the underlying data moved. A worked caching example is in [the rate limits guide](/start/guides/handling-rate-limits#cache-responses-and-pace-batch-work). - **Never poll for delivery status.** Polling `GET /v3/messages/{id}` for every message in a campaign consumes the same pool your sends need. Subscribe to the [webhook events](/start/webhooks/event-types) `message.delivered`, `message.failed`, `message.filtered`, and `message.blocked` instead: delivery outcomes arrive as they happen and cost you zero requests. See [Message status tracking](/start/guides/message-status-tracking). ## Queue sends for sustained volume For continuous high volume, as opposed to one-off campaigns, run sends through a persistent job queue (BullMQ, Sidekiq, Celery, or similar). The queue mechanics belong to your infrastructure; the Sent-specific requirements are: - One job per batch of up to 1,000 recipients, so each job makes exactly one `POST /v3/messages` call. - Worker concurrency capped so combined throughput stays under 200 requests per minute. - An idempotency key per job, so queue retries replay the original response instead of double-sending. See [How to retry Sent API requests safely](/start/guides/retrying-requests-safely). - The `message_id` values from each `202` response persisted, so webhook events can be matched back to jobs. The worked version of this pattern, including queue-depth monitoring, is in [Batch Operations](/start/guides/batch-operations#queue-based-processing). A `202` response means the batch was accepted, not delivered. Individual messages can still end `FAILED`, `FILTERED`, or `BLOCKED` asynchronously. Reconcile accepted IDs against webhook events rather than treating the `202` as delivery confirmation. ## Verify your throughput Your integration is performing correctly when: - A campaign of *N* recipients completes in roughly *N* / 1,000 requests. If your request count is near *N*, you are not batching. - Steady-state traffic produces no `429` responses, and your metrics would show a spike if it started to. - Delivery outcomes arrive through webhooks, and your request log shows no per-message status polling. If you hit sustained `429`s after batching, caching, and pacing, your legitimate volume exceeds the account limit. Contact [support@sent.dm](mailto:support@sent.dm) about an increase, as described in [the Rate Limits reference](/reference/api/rate-limits#increasing-rate-limits). ## Related pages - [Rate Limits](/reference/api/rate-limits): limit values, window semantics, headers, and the `429` body - [How to handle Sent API rate limits](/start/guides/handling-rate-limits): backoff, monitoring, and throttling implementations - [Batch Operations](/start/guides/batch-operations): campaign loops, bulk imports, and queue-based processing - [How to retry Sent API requests safely](/start/guides/retrying-requests-safely): idempotency keys for retried mutations ================================================================================ SOURCE: https://docs.sent.dm/llms/start/advanced/sub-account-profiles-api.txt TITLE: Create and activate sub-account profiles via the API ================================================================================ URL: https://docs.sent.dm/llms/start/advanced/sub-account-profiles-api.txt Create sub-account Sender Profiles with the Sent API: inheritance and billing flags, async completion with a webhook callback, and verifying activation. # Create and activate sub-account profiles via the API This guide shows you how to provision a sub-account [Sender Profile](/start/concepts/sender-profiles) entirely through the API: create the profile, run the asynchronous completion step, handle the webhook callback, and confirm the profile is ready to operate. It is written for platforms and resellers that onboard tenants programmatically; for the dashboard flow, see [Creating a Sender Profile](/start/guides/creating-a-sender-profile), for the underlying concepts, see [Sender Profiles](/start/concepts/sender-profiles), and for choosing between shared and per-tenant architectures, see [Multi-Tenant Architectures](/start/advanced/multi-tenant-architectures). Before you start, you need: - An **organization account**. Call [`GET /v3/me`](/reference/api/account/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint): the response `type` must be `organization`. - An organization API key whose user has the **`admin` role** in the organization. See [roles and permissions](/reference/api/roles-and-permissions) for how roles are granted. - A WhatsApp Business Account: either your organization has completed WhatsApp Embedded Signup, or you have direct credentials (`waba_id`, `phone_number_id`, `access_token`) from a Meta Business Manager System User with `whatsapp_business_messaging` and `whatsapp_business_management` permissions. All requests authenticate with the `x-api-key` header. ## Provision and activate the profile ### Choose inheritance, sharing, and billing settings Decide these before creating the profile: they determine which resources the sub-account shares with your organization and who pays for its messaging. **Inheritance and sharing flags:** | Field | Default | Effect | | --- | --- | --- | | `inherit_contacts` | `true` | Profile reads the organization's contacts | | `inherit_templates` | `true` | Profile reads the organization's templates | | `inherit_tcr_brand` | `true` | Profile uses the organization's TCR brand registration | | `inherit_tcr_campaign` | `true` | Profile uses the organization's TCR campaign | | `allow_contact_sharing` | `false` | Profile's own contacts become visible to other profiles | | `allow_template_sharing` | `false` | Profile's own templates become visible to other profiles | If your tenants must not see each other's data, set `inherit_contacts` and `inherit_templates` to `false` and leave the sharing flags off. If your tenants are brands that need their own US A2P registration, set `inherit_tcr_brand` and `inherit_tcr_campaign` to `false` and supply a `brand` object in the create request (next step). To run all tenants under your own registration, keep the defaults. **Billing model** (`billing_model`, default `profile`): | Value | Who is billed | Requirements | | --- | --- | --- | | `organization` | The organization's billing details are used | No profile-level billing info | | `profile` | The profile is billed independently | `billing_contact` required | | `profile_and_organization` | Profile billed first, organization as fallback | `billing_contact` required | Use `organization` when your platform absorbs messaging costs and invoices tenants itself. Use `profile` when each tenant pays Sent directly. Use `profile_and_organization` when tenants pay directly but you guarantee their usage. With `profile` or `profile_and_organization` you may also pass `payment_details` (card number, `MM/YY` expiry, CVC, ZIP code); card details are never stored on Sent's servers and are forwarded directly to the payment processor. Passing `payment_details` with `billing_model: "organization"` is rejected. ### Create the profile Send `POST /v3/profiles`. Only `name` is required to create, but set `short_name` (3–11 characters: letters, numbers, and spaces, with at least one letter) and `description` now: the completion step in this guide rejects profiles that lack them. For a tenant with its own TCR brand and direct billing: ```bash curl -X POST "https://api.sent.dm/v3/profiles" \ -H "x-api-key: $ORG_API_KEY" \ -H "Idempotency-Key: create-profile-tenant-42" \ -H "Content-Type: application/json" \ -d '{ "name": "Tenant 42 Coffee Co", "short_name": "T42COFFEE", "description": "Sender profile for tenant 42", "inherit_contacts": false, "inherit_templates": false, "inherit_tcr_campaign": false, "billing_model": "profile", "billing_contact": { "name": "Tenant 42 Coffee Co", "email": "billing@tenant42.example.com", "phone": "+12025551234", "address": "123 Main Street, New York, NY 10001, US" }, "brand": { "contact": { "name": "Jane Doe", "businessName": "Tenant 42 Coffee Co", "email": "jane@tenant42.example.com" }, "business": { "legalName": "Tenant 42 Coffee Company LLC", "country": "US" }, "compliance": { "vertical": "PROFESSIONAL", "brandRelationship": "SMALL_ACCOUNT", "isTcrApplication": true } } }' ``` Providing `brand` implicitly sets `inherit_tcr_brand` to `false`; it cannot be combined with `inherit_tcr_brand: true`. If you keep the organization's registration instead, omit `brand` and the inheritance flags, but read the callout in the next step first. If the profile needs its own WhatsApp Business Account, add direct credentials to the request body: ```json "whatsapp_business_account": { "waba_id": "123456789012345", "phone_number_id": "987654321098765", "access_token": "EAAxxxxxxxxxxxxxxx" } ``` Omit the field to inherit the organization's WhatsApp Business Account instead. If you omit it and the organization has not completed WhatsApp Embedded Signup, the request fails with HTTP 422. A successful request returns `201` with the profile in `data`, including its `id` (every later call needs it) and `"status": "incomplete"`. The `Idempotency-Key` header makes retries safe; responses are cached for 24 hours per key. Refer to the [create profile reference](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint) for the full field list. ### Start completion with a webhook URL Completion validates the profile, connects it to the SMS and WhatsApp providers, and sets its final status. It runs in the background, so the request requires a `webHookUrl` that Sent calls when processing finishes, on success or failure: ```bash curl -X POST "https://api.sent.dm/v3/profiles/$PROFILE_ID/complete" \ -H "x-api-key: $ORG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "webHookUrl": "https://your-app.example.com/webhooks/profile-complete" }' ``` A `202` response (`"Profile completion in progress"`) means validation passed and processing started. A `200` means the profile was already completed; the body contains its current status. A `400` means a prerequisite failed: the profile needs `name`, `short_name`, and `description` set, its own KYC submission, an available TCR brand (its own or the organization's), and, for TCR applications, at least one campaign, either inherited or created with [`POST /v3/profiles/{profileId}/campaigns`](/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint). Completion requires the profile itself to have brand (KYC) information on file, even when `inherit_tcr_brand` is `true`. When provisioning entirely through the API, include the `brand` object in the create request. A profile that inherits the TCR brand cannot receive a `brand` object through the API, so its KYC information must be submitted from the dashboard before completion succeeds. ### Handle the completion callback When the background process finishes, Sent sends a `POST` request with a JSON body to your `webHookUrl`: ```json { "profileId": "770e8400-e29b-41d4-a716-446655440002", "success": true, "status": "COMPLETED", "timestamp": "2026-07-25T14:03:11.402Z" } ``` | Field | Meaning | | --- | --- | | `profileId` | UUID of the profile that finished processing | | `success` | `true` if provisioning finished, `false` if it failed | | `status` | `COMPLETED` or `SUBMITTED` on success; `failed` on failure | | `timestamp` | When the callback was generated (UTC) | `COMPLETED` means the profile is fully activated. `SUBMITTED` means processing finished but the profile is not yet sendable. The profile lands there when either the SMS or WhatsApp channel configuration is missing, when a dedicated (non-inherited) TCR brand or campaign has not yet been submitted to TCR, or when a non-TCR profile declares a main destination country, which requires more information before activation. A minimal receiver: ```typescript app.post("/webhooks/profile-complete", (req, res) => { res.sendStatus(200); const { profileId, success, status } = req.body; if (success) { // status is "COMPLETED" (ready) or "SUBMITTED" (pending registration/config) markTenantProvisioned(profileId, status); } else { // status is "failed", retry the complete call or investigate flagTenantProvisioningFailure(profileId); } }); ``` Sent calls the webhook once and does not retry. If your endpoint misses the callback, poll the profile status (next step) instead. ### Verify the profile Confirm the outcome with `GET /v3/profiles/{profileId}`: ```bash curl "https://api.sent.dm/v3/profiles/$PROFILE_ID" \ -H "x-api-key: $ORG_API_KEY" ``` The `status` field reports the public setup state: `approved` corresponds to the `COMPLETED` webhook status, `submitted` to `SUBMITTED`, `processing` means completion is still running, and `failed` means it did not finish. To see the profile in context, call `GET /v3/me` with your organization key: the `profiles` array lists every child profile with its `status` and the calling user's `role` in it. ### Operate on the child profile To call any `/v3` endpoint as the new profile, add the `x-profile-id` header to a request made with your organization API key: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $ORG_API_KEY" \ -H "x-profile-id: $PROFILE_ID" \ -H "Content-Type: application/json" \ -d '{ "to": ["+15551234567"], "template": {"id": "tmpl_123"} }' ``` The request executes as the profile (its templates, contacts, numbers, and settings apply) and the response echoes the scope in an `X-Profile-Id` header. Only organization API keys can use `x-profile-id`; profile-scoped keys are rejected with `403`, and a profile ID outside your organization returns `404`. If you prefer to hand tenants their own credentials instead of proxying through your organization key, each profile also has its own API key. See [API credentials and isolation](/start/concepts/sender-profiles#api-credentials-and-isolation). ## Reuse sender numbers across profiles If a new profile should send from a number already provisioned on another profile, reference that profile instead of provisioning a new number. Update the profile with `PATCH /v3/profiles/{profileId}`: - `sending_phone_number_profile_id`: use another profile's SMS number and provider configuration. Completion then copies that configuration instead of provisioning a new SMS setup. - `sending_whatsapp_number_profile_id`: use another profile's WhatsApp number configuration. ```bash curl -X PATCH "https://api.sent.dm/v3/profiles/$PROFILE_ID" \ -H "x-api-key: $ORG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sending_phone_number_profile_id": "660e8400-e29b-41d4-a716-446655440000" }' ``` When a profile inherits the organization's WhatsApp Business Account at creation, `sending_whatsapp_number_profile_id` is set to the organization automatically. Set the referenced profile IDs before calling the completion endpoint so the copied configuration counts toward the channel checks that decide `COMPLETED` versus `SUBMITTED`. Refer to the [update profile reference](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint) for all updatable fields, including `allow_number_change_during_onboarding`. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | | `403` "You do not have admin access to this organization" on create | Your API key's user lacks the `admin` role | Ask the organization owner to grant the `admin` role (see [roles and permissions](/reference/api/roles-and-permissions)) | | `422` "Organization does not have a WhatsApp Business Account configured" | `whatsapp_business_account` omitted and no Embedded Signup completed | Complete Embedded Signup for the organization, or pass direct WABA credentials | | `400` "Profile ShortName is required" or "Profile Description is required" on complete | Profile was created with only `name` | `PATCH` the missing fields, then retry | | `400` "KYC form submission is required before completing profile" | The profile has no brand (KYC) submission of its own | Include `brand` in the create request, or submit KYC from the dashboard for inheriting profiles | | `400` "TCR applications must have at least one campaign before completing profile" | Dedicated TCR registration without a campaign | Create a campaign for the profile's brand, or set `inherit_tcr_campaign: true` | | `400` "Missing required compliance documents for the following countries: …" | A main destination country requires documents | Upload the listed documents in the dashboard, then retry | | `403` "Profile API keys cannot use x-profile-id" | Scoping header sent with a profile-scoped key | Use an organization API key, or drop the header | | Webhook arrives with `success: false` | Background provisioning failed | Retry the complete call; if it keeps failing, contact support with the `X-Request-Id` response header | ## Related pages - [Sender Profiles](/start/concepts/sender-profiles) - the inheritance model - [Creating a Sender Profile](/start/guides/creating-a-sender-profile) - the dashboard flow - [Multi-Tenant Architectures](/start/advanced/multi-tenant-architectures) - choosing shared vs. per-tenant setups - Refer to the [create](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint), [complete](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint), and [update](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint) profile references for the full list of fields and responses - [10DLC Registration](/start/advanced/10dlc-registration) - registering campaigns for US SMS ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/10dlc.txt TITLE: What is 10DLC? ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/10dlc.txt Why carriers in the United States require 10DLC registration for A2P SMS, how The Campaign Registry reviews brands and campaigns, and where Sent fits in # What is 10DLC? 10DLC stands for 10-Digit Long Code. It is the US registration system for A2P (Application-to-Person) SMS: messages sent by software to people, as opposed to two people texting each other. If your business sends texts to US phone numbers, 10DLC applies to you. This page explains why the system exists, how a registration is reviewed, and where Sent fits in, so the requirements in the [10DLC Registration Guide](/start/advanced/10dlc-registration) read as a coherent system rather than a list of arbitrary rules. ## Why carriers built a registration system For years, businesses could send SMS from regular 10-digit numbers with no registration required. Carriers (AT&T, Verizon, T-Mobile) couldn't tell a legitimate business from a bot farm, so in 2019 they built a gating system: unregistered A2P traffic is filtered or throttled, and registered traffic is treated as legitimate. Registration happens through **TCR (The Campaign Registry)**, which acts as the central record-keeper for all 10DLC submissions. You tell TCR who you are and what you're sending. In return, carriers treat your messages as legitimate traffic. The distinction TCR polices is A2P versus P2P (Person-to-Person): a person texting a friend needs no registration, while software texting customers does, even at a small volume of appointment reminders. Virtually all messages sent through a platform like Sent are A2P. TCR plays the same role for US SMS that Meta's Business Verification plays for WhatsApp: an identity layer that lets the network attach a reputation to a sender. The difference is that 10DLC is carrier-imposed and US-specific, while WhatsApp's rules are global. ## Brands and campaigns: the two halves of a registration Every 10DLC submission has two parts, reviewed very differently. A **brand** is your business identity: legal name, address, tax ID. TCR verifies your business is real using the information you provide. This review is algorithmic, fast, and mostly a lookup against existing business registration data. A **campaign** describes the messages themselves: the type of messages you're sending, who's receiving them, how they opted in, and sample examples. Unlike brand verification, a campaign is **manually reviewed by a human**, which is where things get inconsistent. The same campaign description can pass or fail depending on who reviews it, and rejection error codes (805, 806, 851) give you a category of problem rather than specific guidance on what to fix. This split explains most of the friction in the process. The brand check rarely fails for a real business, but campaign review is subjective, so the documented content rules (opt-in forms, autoresponses, sample messages) exist to remove any grounds a reviewer might have for rejection. ## Volume tiers and trust Campaigns are tiered by declared volume. Below 2,000 messages per day, a campaign qualifies as **low volume**, with lower registration fees and fewer declared use cases (up to 5, rather than 11 for a standard campaign); past that threshold it registers as standard (high volume). TCR also assigns each brand a trust score, and carriers use it to set throughput limits: a better-established brand can send faster. Some senders register the cheapest tier or a convenient use case and plan to correct it later. This is usually counterproductive: carriers compare your registered use case against your actual traffic, and mismatches lead to filtering or campaign suspension. Registering what you actually send is the reliable path. ## Where Sent fits Sent sits between you and TCR. You fill out one form in the Sent dashboard (or submit via the API), and the Sent compliance team reviews your submission before it reaches TCR. Because campaign review is the subjective, human part, this pre-review catches the problems that commonly cause rejections. Sent then submits the registration on your behalf, tracks the review, and notifies you when you're approved or when something needs fixing. You never interact with TCR directly, but the registration is still legally yours: TCR ties each campaign to the business responsible for the messages, which is why agencies and resellers must register each client separately rather than bundling them under one campaign. ## Where to go from here Now that you understand what 10DLC registration involves, you can act on it: - [10DLC Registration Guide](/start/advanced/10dlc-registration): register your brand and campaign through the Sent dashboard - [Register 10DLC Campaigns via the API](/start/advanced/10dlc-campaigns-api): programmatic registration for agencies and resellers - [Compliance & Regulations](/start/advanced/compliance-regulations): what Sent enforces at send time, and per-region regulations beyond the US - [Compliance & 10DLC Issues](/troubleshooting/compliance): fixes when 10DLC errors block your sending ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/api-authentication.txt TITLE: API Authentication ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/api-authentication.txt Why the Sent API v3 authenticates every request with a single API key sent as a request header, covering the security model, trade-offs, and OAuth comparison. # API Authentication Every request to the Sent API v3 carries exactly one credential: an API key in the `x-api-key` header. There is no OAuth handshake, no token refresh, and no session. This page explains why authentication works this way, what the design trades away, and what it asks of you in return. ## One key, resolved on every request When a request arrives, the API looks up the presented key, resolves it to your customer account, and attaches that identity to the request. Nothing persists between calls: each request is authenticated independently, and the server keeps no session on your behalf. That is why the API has no login step: the key is the entire credential on every call. This model has a name in HTTP security: the key is a *bearer credential*. Whoever presents it is treated as the account. Most of the security guidance around Sent API keys follows from that single property. ## Why a header, not a URL or body A credential can travel in three places in an HTTP request: the query string, the body, or a header. Query strings are the worst home for a secret, because they are routinely written to server access logs, proxy logs, and browser history, so a credential in a URL leaks by default. The request body is safer, but it ties authentication to methods that have bodies, leaving `GET` requests with nowhere to put the credential. A header is the remaining channel: it is uniform across HTTP methods and conventionally excluded from logs. The same reasoning leads most API-key platforms to the same design. A header is plaintext inside the connection, so its confidentiality rests entirely on TLS. This is why the API is served over HTTPS, and why a key should never travel over an unencrypted channel. ## Why not OAuth OAuth exists to solve delegation: a third-party app acting on a user's behalf without ever seeing the user's password. Calls to the Sent API are not delegated (your backend acts on your own account), so an OAuth flow would add token issuance, expiry, and refresh without adding a security boundary. Some platforms use OAuth client-credentials even for first-party, server-to-server APIs, because short-lived tokens bound how long a leaked credential remains useful. That is a reasonable approach, but it means every integration must implement token acquisition and refresh before it can make its first call. Sent chooses the other side of that trade: a static key makes the first request a one-liner, and containment comes from revocation and rotation. You can disable or delete any key in the dashboard, and it stops working immediately. ## What the trade-off asks of you Because the key is a static bearer credential, the burden the design places on you is key handling. Each of the standard practices exists for a specific reason: - **Environment variables instead of source code**, because repository history is effectively permanent: a key committed once remains recoverable even after you delete it. - **Server-side use only**, because anything delivered to a browser or bundled into a mobile app is public; a key there is equivalent to publishing it. - **One key per environment**, because separate keys contain the blast radius of a leak and let you rotate development credentials without touching production. - **Periodic rotation**, because rotation bounds the useful lifetime of a key that leaked without your knowledge. All four practices answer the same underlying fact: possession of the key is authorization. The how-to guide [Creating and managing API keys](/start/guides/api-keys) turns them into concrete steps. ## Guarding against brute force A single static credential invites guessing, so the API tracks consecutive failed attempts per presented credential and temporarily locks a credential after repeated failures, with lockout periods that escalate from 1 minute up to 60 minutes. The lockout is keyed to the credential itself rather than to the caller's IP address. This is deliberate: many tenants can sit behind one shared ingress or proxy and therefore share an IP, so an IP-keyed lockout would let one misconfigured client (or a scanner probing random keys) lock out every valid tenant behind the same address. Scoping the lockout to the guessed credential means a valid key is never collateral damage. ## Where to act on this - [Creating and managing API keys](/start/guides/api-keys): create, store, verify, and rotate keys. - [Authentication reference](/reference/api/authentication): the exact header, response headers, and AUTH error codes. - [Per-request authentication pattern](/build/authentication): the integration blueprint for multi-tenant apps where each customer supplies their own key. ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/channels.txt TITLE: Channels ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/channels.txt Why Sent routes SMS, RCS, and WhatsApp behind one send call: the three-layer channel model, channel discovery, routing factors, and content adaptation. # Channels A channel is a delivery path for a message: SMS, RCS, or WhatsApp. Most messaging platforms expose each of these as a separate API, which makes the channel the developer's problem, because every send starts with a decision about how the message should travel. Sent inverts that: the channel is a routing decision the platform makes per message and per recipient, behind a single send call. This page explains why Sent built the model that way, what each channel contributes, and which factors drive routing. ## Intent-Based Messaging When each channel is its own API, channel management leaks into your app code: separate integrations, separate content formats, and hand-written fallback logic. Sent replaces channel selection with **intent-based messaging**. You state what to send and to whom, and the platform works out the delivery path: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); await client.messages.send({ to: ['+14155551234'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', parameters: { name: 'John Doe' }, }, }); ``` This call omits the `channel` field, so Sent chooses the route. That is the default behavior, equivalent to `channel: ["sent"]`. Sent discovers the channels available to each recipient, selects a route, and adapts the content format to the channel it selects. The same call can therefore produce a WhatsApp delivery for one recipient and an SMS delivery for another, without any branching in your code. When a specific channel matters (a compliance requirement, for example), the `channel` field pins the route. [Channel Selection Strategies](/start/guides/sending-messages#channel-selection-strategies) covers the request syntax and its trade-offs. ## The Three-Layer Channel Model Sent supports three channels, and they play deliberately different roles: - **SMS: The universal foundation.** SMS works on every mobile device with cellular connectivity, rides carrier infrastructure, and defines the baseline capabilities every template must support. It's also the final fallback when richer channels can't deliver. - **RCS: The carrier-native rich layer.** RCS adds interactive suggestion chips and a branded, verified sender over carrier networks, natively in Google Messages on Android, with no separate app install. It bridges the gap between SMS reach and WhatsApp interactivity for Android users. - **WhatsApp: The rich engagement layer.** WhatsApp delivers media, buttons, and branded business profiles, supports conversation-based interactions, and reports read receipts. The layering is the point: SMS guarantees reach, RCS and WhatsApp add richness where the recipient's device and registration allow it, and routing decides, per message, which layer applies. ## One Template, Every Channel Because every message starts from a [template](/start/concepts/templates), content adapts to the capabilities of whichever channel ends up carrying it: - **Universal compatibility**: every template starts from content that works on SMS, the most constrained channel, so every message is deliverable everywhere. - **Automatic enrichment**: delivered over WhatsApp, the same template gains interactive buttons, media headers, and branded formatting. - **Graceful degradation**: complex WhatsApp templates simplify for SMS delivery, preserving the essential information. The same template renders differently on each channel: | Message Type | SMS | RCS | WhatsApp | |--------------|-----|-----|----------| | **Authentication** | "Your verification code is 123456" | Branded text from your verified sender with a "Got it" suggestion chip | Rich template with branding, verification code, security tips, and interactive "Got it" button | | **Order Update** | "Order #12345 shipped. Track: [link]" | Branded text with a "Track Order" suggestion chip | Rich card with product image, detailed status, tracking button, and customer service contact | ## Channel Discovery Channel availability is a property of each recipient, not of your account. When Sent first encounters a phone number, it establishes which channels can reach it: carrier and number-type data determine SMS and RCS deliverability, and Sent checks WhatsApp registration status separately. Providers also report what each channel supports (media, templates, inbound messages), so routing knows whether a channel is reachable and what it can carry. Sent stores the result on the contact as its available channels and default channel, and the result can change over time. A recipient who registers for WhatsApp after months of SMS-only delivery can start receiving WhatsApp messages without any change to your code. ## Routing Factors When more than one channel can reach a recipient, the router weighs a small set of factors: - **Region**: country-specific regulation and carrier policy constrain which routes Sent can use. - **Content**: media and interactive elements route to channels that support them; plain text can travel on any channel. - **Cost**: among viable routes, the router prefers the cheaper one, based on regional pricing, provider rates, and conversation-window rules. - **Fallback**: when the selected channel can't deliver, the router retries on the next viable channel, ending at SMS. Fallback applies only to [automatic selection](/start/guides/sending-messages#automatic-selection-default). A request that pins the `channel` field is a broadcast instruction, not a fallback order: each listed channel produces its own message, and a pinned channel that can't deliver fails rather than falling back. ## Content Adaptation Mechanics Adaptation happens at send time, per route: - **Character limits**: rich templates compress to fit SMS constraints while preserving the essential information and the call to action. - **Media**: images and videos become descriptive text with links on SMS and RCS; WhatsApp receives the full media experience. - **Interactive elements**: buttons and quick replies become text alternatives with URL links on SMS, and they remain fully interactive on RCS and WhatsApp. ## What Each Channel Brings ### RCS **Carrier-native delivery**: RCS messages travel over carrier networks directly into Google Messages, the default Android messaging app, so recipients install nothing. **Branded sender**: unlike SMS, which shows a phone number, RCS messages display your company logo, name, and a verified checkmark. **No number provisioning**: RCS does not use phone numbers. Each business operates an "RCS Agent" (a branded sender identity), so you don't need short codes or carrier number registration like SMS requires. **Suggestion chips**: RCS messages sent through Sent include suggestion chips: quick reply, open URL, and dial number buttons displayed below the message. Sent maps up to four template buttons to chips per message. Suggestion chips aren't available on SMS. **Rich cards and carousels (roadmap)**: rich cards (title, description, image or video, and action buttons) and carousel cards (scrollable sets of rich cards) are part of the RCS standard but aren't yet available through Sent. RCS messages currently render as text plus suggestion chips. **Read receipts**: like WhatsApp, RCS delivers read receipts when the recipient opens the message. SMS has no equivalent. **Built-in STOP chip**: every RCS message automatically includes a STOP button for compliance, so you don't need to add opt-out footer text. A tap on the chip arrives as an ordinary inbound STOP message and opts the contact out, exactly as texting STOP does on SMS. [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent) shows how to mirror those opt-outs into your own systems, and the [events reference](/start/webhooks/event-types) documents the inbound webhook payload. **Automatic fallback**: when RCS can't reach a recipient (unsupported device or carrier), automatic channel selection falls back to SMS. **Sender Profile verification**: RCS requires an approved RCS Sender Profile (logo, verified name, brand color) before you can send messages, managed through the Sent dashboard. RCS sender setup is not self-service. Unlike SMS and WhatsApp, which can be activated in the dashboard, RCS requires a one-time approval process with carriers. Contact Sent to get started. ### WhatsApp **Managed template approval**: Sent submits new templates to Meta and tracks their approval status, so you never work in Meta's platform directly. **Business verification**: WhatsApp Business accounts display verification badges and professional profiles, which recipients trust more readily than SMS from an unknown number. ### SMS **Carrier network integration**: SMS routing is aggregator- and carrier-agnostic. Sent validates phone numbers in real time, assesses SMS capability, and routes through carrier networks without depending on a single provider. **Regulatory compliance**: Sent handles regional SMS requirements, carrier filtering, spam prevention, and opt-out management automatically for each country. **Predictable economics**: a straightforward per-message pricing model keeps cost calculation and customer billing transparent. ## Further Reading The channel model means fallback logic, regional preferences, and platform-specific formats live in the platform rather than in your code. To act on it: - [Sending Messages](/start/guides/sending-messages): send over specific channels, broadcast to several, or send free-form text. - [Channel Setup](/start/quickstart/channel-setup): activate SMS, WhatsApp, and RCS on your account. - [Unified Messaging Intelligence](/start/concepts/unified-messaging): the broader intent-based delivery model that channel routing sits inside. ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/contacts.txt TITLE: Contacts ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/contacts.txt What a contact is in Sent - a validated, channel-aware communication endpoint that carries availability, formatting, and routing state for a phone number # Contacts A contact in Sent is a validated communication endpoint: a phone number enriched with the channel information the platform needs to reach its owner. Rather than forcing developers to manage the complexity of different messaging channels, contacts provide a unified abstraction that handles channel validation, metadata, and routing automatically. ## Contacts Architecture The Sent Contacts Architecture is designed to be intelligent and flexible, while allowing you to have full control over the messaging experience. ### Channel-Agnostic Abstraction Traditional messaging platforms require developers to understand and integrate with separate APIs for SMS, WhatsApp, and other channels. This leads to several problems such as integration complexity, validation overhead, routing decisions, and fallback handling, all of which need to be implemented manually. Sent solves these problems by providing a **channel-agnostic abstraction**. When you send a message to a contact, the platform handles all channel-specific complexity behind a unified interface. ### The Contact as a Validated Communication Endpoint A contact in Sent is fundamentally different from a simple phone number or user record. It represents: **A validated, intelligent communication endpoint** that: - Knows which messaging channels can successfully reach the recipient - Understands the optimal channel for delivery based on regional preferences and availability - Maintains channel-specific formatting and validation rules - Provides automatic fallback routing when primary channels fail This abstraction allows developers to focus on their app logic rather than the intricacies of multi-channel messaging infrastructure. ### Channel Availability and Routing A contact records which channels can currently reach its phone number. SMS, WhatsApp, and RCS are all live channels in Sent, and a contact carries a per-channel availability record for each. When you send without pinning a channel, routing selects among the contact's available channels per recipient at send time. There is no fixed channel preference order: the winning route depends on the routing rules configured for your account and the recipient, and the remaining candidates are kept as fallbacks. The factors the router weighs are summarized in [Routing Factors](/start/concepts/channels#routing-factors); [Unified Messaging Intelligence](/start/concepts/unified-messaging#the-decision-engine) explains the decision engine, and the [channel routing reference](/reference/channel-routing) specifies the exact matching and ordering rules. ### Persistent State Contacts maintain persistent state that benefits future interactions: - **Channel availability** is cached and periodically refreshed - **Routing decisions** improve based on delivery history - **Validation status** prevents repeated failed delivery attempts This persistence means that applications naturally become more efficient over time without additional developer effort. ### Phone Number Normalization Contacts handle the complexity of phone number formatting across different contexts: - **E.164 format**: International standard for programmatic use - **International format**: Human-readable international representation - **National format**: Localized formatting for user interfaces - **RFC format**: Standardized format for system integration This multi-format support ensures that contacts work correctly regardless of how phone numbers are provided or displayed in your app. ### Customer Scoping and Isolation Contacts are scoped to individual customers, providing: - **Access control**: Customers can only access their own contacts - **Billing isolation**: Message costs are attributed correctly - **Data privacy**: Contact information remains segregated between customers This architecture supports multi-tenant applications while maintaining security and billing accuracy. ### Channel-Specific Metadata While contacts abstract channel complexity, they also preserve channel-specific information when needed: - **WhatsApp profile status**: Whether a number has an active WhatsApp Business API registration - **SMS carrier information**: Network-specific delivery capabilities - **Regional restrictions**: Compliance and availability constraints by geography This metadata enables advanced applications to make informed decisions while still benefiting from the unified API approach. ## Contact Intelligence ### Automatic Contact Creation Contacts follow a **lazy creation** pattern that reduces integration friction. This approach means developers never need to pre-register contacts. The system creates and optimizes them automatically during the first messaging interaction. Here's how it works: ### Real-Time Channel Validation When a contact is created, and again as messages are sent to it, Sent validates the phone number against the supported channels: - **SMS availability**: verified through carrier network validation - **WhatsApp registration**: checked against the WhatsApp Business API - **RCS capability**: checked against the recipient device's RCS support, with the result cached for reuse This validation ensures that every contact represents a **deliverable endpoint**, reducing bounce rates and failed message attempts. ### Contact Enrichment Over Time Contacts become more intelligent through usage: - **Delivery success patterns** inform future routing decisions - **Channel preferences** are learned from recipient engagement - **Validation status** is periodically refreshed to maintain accuracy This creates a feedback loop where messaging performance improves automatically as the system learns more about each contact. ## Contacts for Developers Sent supports two primary patterns for working with contacts. Both patterns use the same underlying intelligence but offer different levels of control and visibility. **Direct phone messaging** (automatic contact creation): - Send immediately to any phone number - Contact created and optimized transparently - Ideal for one-off or ad-hoc messaging **Explicit contact management**: - Retrieve and work with contact objects - Access channel availability metadata - Suitable for applications that need contact state awareness [Managing Contacts](/start/guides/managing-contacts) covers both patterns in practice. **When building with Sent's contact system, developers should shift their mental model to a contact-based approach** Avoid the traditional approach of branching on channel in your own code: ```javascript if (user.hasWhatsApp) { sendWhatsApp(message, user.phone); } else { sendSMS(message, user.phone); } ``` Prefer the contact-based approach, where the platform picks the channel: ```javascript sendMessage(message, contact); ``` --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts.txt TITLE: Core Concepts Overview ================================================================================ URL: https://docs.sent.dm/llms/start/concepts.txt The fundamental concepts behind the Sent messaging platform - unified messaging, channels, contacts, templates, Sender Profiles, and how they fit together # Core Concepts Overview Before you start building with Sent, it's important to understand the fundamental concepts that make up the Sent messaging platform. This section will help you build a mental model of how Sent works and why it's designed the way it is. ## Learning Path ### Platform Overview Start here to understand the high-level architecture and how Sent fits into your application stack. [Read more →](/start/concepts/platform-overview) **You'll learn:** - The components of Sent's architecture - How messages flow through the system - Key benefits for developers and businesses ### Unified Messaging Understand the philosophy behind intent-based messaging and how it differs from traditional channel-specific approaches. [Read more →](/start/concepts/unified-messaging) **You'll learn:** - Imperative vs declarative messaging - How the decision engine works - Automatic fallback and retry logic ### Channels Learn about the intelligent abstraction layer that handles SMS, WhatsApp, RCS, and future messaging channels. [Read more →](/start/concepts/channels) **You'll learn:** - How channel selection works - How one template adapts to each channel - Real-time availability detection ### Contacts Understand how Sent's contact system provides intelligent, channel-agnostic communication endpoints. [Read more →](/start/concepts/contacts) **You'll learn:** - Contact lifecycle and validation - Routing decision engine - Phone number normalization ### Templates Learn about the foundation of unified messaging and how templates adapt across channels. [Read more →](/start/concepts/templates) **You'll learn:** - Template anatomy and components - Dynamic content handling - Content adaptation mechanics ## Concept Map } /> } /> } /> } /> } /> } /> } /> } /> ## Key Principles ### 1. Intent Over Implementation Sent focuses on *what* you want to achieve (send a notification) rather than *how* (SMS vs WhatsApp vs RCS). This abstraction allows the platform to optimize delivery without your intervention. ### 2. Richer Where the Channel Allows Every message works on the most constrained channel, SMS, and picks up media, buttons, and branding automatically when it routes to a richer channel. ### 3. Intelligence Through Data Delivery outcomes feed back into routing: delivery patterns and success rates inform the routing rules that decide how future messages are sent. ### 4. Developer Experience First Complexity is absorbed by the platform, not pushed to developers. You shouldn't need telecom expertise to send messages reliably. ## How These Concepts Work Together ``` Your Application ↓ [Intent] → "Send order confirmation to customer" ↓ [Contact] → Intelligent endpoint with channel info ↓ [Template] → Channel-agnostic message definition ↓ [Decision Engine] → Optimal channel selection ↓ [Channel] → SMS, WhatsApp, RCS, etc. ↓ Delivery with automatic fallback ``` **Ready to build?** Once you understand these concepts, move on to the [Quickstart](/start/quickstart) to send your first message, or dive into the [Implementation Guides](/start/guides) for detailed how-tos. ## Common Questions **Do you need to understand all these concepts before using Sent?** No. You can start sending messages with just the [Quickstart](/start/quickstart). These concepts help you build more effectively and troubleshoot issues. **Which concept should you learn first?** Start with [Platform Overview](/start/concepts/platform-overview) for the big picture, then [Unified Messaging](/start/concepts/unified-messaging) to understand the core philosophy. **How do concepts relate to the API?** Concepts map directly to API resources: - Contacts → `/v3/contacts` - Templates → `/v3/templates` - Messages → `/v3/messages` --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/platform-overview.txt TITLE: Platform Overview ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/platform-overview.txt Understanding Sent's intelligent messaging architecture and how it powers omnichannel communication # Platform Overview Sent is an intelligent messaging platform that simplifies omnichannel communication through a unified API. By abstracting the complexity of multiple messaging channels, Sent enables developers to focus on building great user experiences while Sent handles delivery optimization, routing, and reliability. ## How Sent Works Sent acts as an intelligent middleware layer between your app and various messaging channel providers (SMS, WhatsApp, RCS, etc.). When you send a message through Sent's API, the platform automatically determines the optimal delivery path based on multiple factors including cost, speed, delivery success rates, and recipient preferences. ## Core Architecture Components ### Unified API Layer Your single integration point for all messaging channels. Send messages across SMS, WhatsApp, RCS, and more through one consistent API interface. No need to manage multiple provider integrations or handle different authentication schemes. **Key Features:** - Single authentication mechanism across all channels - Consistent request/response format - Built-in rate limiting and retry logic ### Message Delivery Intelligence Sent's intelligent routing engine makes real-time decisions about how to deliver your messages. Each decision weighs: - **Cost Efficiency**: Automatically selects the most cost-effective channel - **Success Rate**: Uses historical data to predict and optimize delivery - **Geographic Optimization**: Considers recipient location and local channel preferences ### Smart Routing & Failover When a message cannot be delivered through the primary channel, Sent automatically attempts delivery through fallback channels without any additional code on your end. **Routing Considerations:** - Recipient channel availability (WhatsApp installed, RCS-capable device/carrier, internet connected, etc.) - Message content: text, media, or interactive components such as RCS suggestion chips and rich cards - Regulatory compliance and regional restrictions ### Real-Time Analytics & Webhooks Track every message through its entire lifecycle with detailed analytics and real-time webhook notifications. - Delivery confirmations - Read receipts (WhatsApp and RCS; not available on SMS) - Bounce and failure reasons - Aggregate performance metrics ## Sent for Developers - **Faster Time to Market**: One integration instead of managing multiple provider SDKs - **Simplified Maintenance**: No need to update code when switching or adding providers - **Better Error Handling**: Unified error codes and automatic retry logic - **Sandbox Testing**: A sandbox mode for every channel ## Sent for Businesses - **Cost Optimization**: Automatic routing to most cost-effective channels - **Higher Delivery Rates**: Intelligent failover and channel selection - **Global Reach**: Support for regional providers and compliance - **Scalability**: Infrastructure that grows with your message volume --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/sender-profiles.txt TITLE: Sender Profiles ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/sender-profiles.txt Why Sent models multi-tenancy as organizations, Sender Profiles, and per-resource inheritance, and how governed sharing isolates brands, tenants, and clients. # Sender Profiles: Multi-Tenant Resource Governance Sender Profiles provide an architectural abstraction that enables true multi-tenancy within a single Sent organization. Rather than creating separate accounts for each business unit, brand, or customer, Sender Profiles allow you to isolate messaging identities while selectively sharing resources across organizational boundaries. This page explains why the model is built this way and what each governance choice trades off; the hands-on work lives in [Creating a Sender Profile](/start/guides/creating-a-sender-profile) and [Integrating Sender Profiles into Your Application](/start/guides/integrating-sender-profiles). ## The Sender Profile Abstraction Traditional approaches to multi-tenant messaging force a binary choice: completely separate accounts with full isolation (and operational overhead), or a shared account with no isolation (and security risks). Sub-accounts in traditional CPaaS platforms mostly reproduce the first option, because every resource of the sub-account is isolated whether that serves you or not. Sender Profiles instead introduce a **governed resource model** that allows selective isolation and sharing on a per-resource basis: an agency can isolate each client's contact list while sharing one template library, something an all-or-nothing sub-account cannot express. ### The Inheritance Architecture Sender Profiles implement a hierarchical resource inheritance model with three levels: - **Organization level**: the root entity that owns billing, compliance, and shared resources. Organizations serve as the administrative boundary and cost center. - **Sender Profile level**: individual messaging identities that can inherit resources from the organization or maintain dedicated resources. Each profile has its own API credentials, sending identity, and configuration. - **Resource level**: individual resources (billing, WhatsApp Business Account, contacts, templates, TCR registration) that can be inherited, shared, or dedicated per profile. Resolution flows downward: when a profile inherits a resource, requests made as that profile transparently use the organization's copy; when the resource is dedicated, the profile's own copy is used and the organization's copy is invisible to it. Because the decision is made per resource rather than per account, the same profile can be fully isolated on one axis and fully shared on another. This model enables use cases like agencies managing multiple client brands, franchises with centralized billing but local operations, or platforms offering white-labeled messaging. ### Resource Governance Model Each resource type implements a specific governance pattern: | Resource | Inheritance Pattern | Use Case | |----------|---------------------|----------| | **Billing** | Organization, Dedicated, or Mixed | Centralized cost control vs. separate invoicing | | **WhatsApp Business Account** | Organization, Profile, or Dedicated | Shared WhatsApp number vs. brand-specific presence | | **RCS Agent** | Dedicated per profile | Each Sender Profile can have its own branded RCS sender identity (logo, verified name) | | **Contacts** | Inherit + Share toggle | Shared customer database vs. isolated lists | | **Templates** | Inherit + Share toggle | Global template library vs. brand-specific content | | **TCR Brand/Campaign** | Full/Partial/Dedicated | Unified compliance vs. separate brand registration | ## Resource Configuration Patterns ### Billing Inheritance Billing governance offers three modes: - **Inherited**: all charges flow to the organization's payment methods. Ideal for centralized cost management and single-invoice accounting. - **Dedicated**: profile-specific billing with separate payment methods. Useful when clients or business units need direct billing. - **Mixed**: the profile starts on the organization's billing but can be switched to its own billing later. Useful when a tenant may eventually take over payment. Inherited billing is the better default for most organizations, because a single invoice and payment method keeps accounting simple; dedicated billing earns its overhead only when a client genuinely needs to be the payer of record. ### Contacts and Templates Contacts and templates implement a dual-mode sharing model: - **Inheritance**: access resources from the organization level - **Sharing**: allow this profile's resources to be accessed by other organization members The two directions are independent, which is what makes the model expressive: a profile can consume the organization's shared template library without exposing its own contact list, or contribute templates upward while keeping its contacts private. When sharing is enabled, any profile inheriting from the organization can access those resources. Consider data privacy implications when sharing contact lists across business units, and document your resource-sharing policy so tenants know what is visible to whom. ### RCS Agent (Branded Sender) RCS uses a branded sender identity (called an RCS Agent) instead of a phone number. Each Sender Profile can have its own dedicated RCS Agent with a custom logo, verified business name, and brand color displayed in the recipient's Google Messages inbox. RCS Agent setup requires a one-time approval process with carriers and cannot be self-activated in the dashboard. Contact Sent to initiate RCS onboarding for your Sender Profile. ### TCR Brand and Campaign For US SMS compliance, TCR registration offers flexible inheritance: - **Fully Inherited**: use the organization's brand and campaign - **Partially Inherited**: inherit the organization's brand but register a dedicated campaign for the profile - **Dedicated**: complete separate TCR registration for this profile The reason partial inheritance exists is that [TCR itself splits registration in two](/start/concepts/10dlc): a brand establishes the business identity behind the messages, while a campaign describes a specific use case being sent. Many multi-tenant setups share one legal entity but send a different use case per tenant, so inheriting the organization's brand while registering a dedicated campaign per profile mirrors how TCR divides responsibility. When tenants are distinct legal entities, dedicated registration is the only honest representation: TCR ties each campaign to the business responsible for the messages, which is why agencies and resellers register each client separately rather than bundling them under one campaign. ## API Credentials and Isolation Each Sender Profile has unique API credentials. The API key presented on a request determines which profile's resources the request uses: templates, contacts, numbers, WhatsApp Business Account, and TCR settings all resolve through the profile the key belongs to. This is the same governance model expressed at the API surface; no request parameter can reach across profile boundaries that the inheritance configuration has not opened. Organization-level API keys are the one deliberate exception: they can act on behalf of a child profile by naming it explicitly (the `x-profile-id` request header), which platforms use to keep a single credential while still routing traffic per tenant. Both approaches, per-profile keys and organization-key scoping, are shown with code in [Integrating Sender Profiles into Your Application](/start/guides/integrating-sender-profiles); the scoping rules and error behavior are documented in [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api). ## Architectural Patterns ### Pattern 1: Agency Model Centralized billing with dedicated WhatsApp Business Account per client: - **Billing**: Inherited (agency pays) - **WhatsApp Business Account**: Dedicated per profile (client branding) - **Contacts**: Dedicated (client data isolation) - **Templates**: Inherited + Shared (agency provides templates) ### Pattern 2: Franchise Model Shared resources with local brand presence: - **Billing**: Inherited (franchisor manages) - **WhatsApp Business Account**: Dedicated per location (local phone numbers) - **Contacts**: Inherited (shared customer base) - **Templates**: Inherited (franchise-wide messaging) ### Pattern 3: Platform/ISV Model Full tenant isolation: - **Billing**: Dedicated (tenants pay directly) - **WhatsApp Business Account**: Dedicated per tenant - **Contacts**: Dedicated - **Templates**: Dedicated The three patterns are points on one spectrum, from maximum sharing to maximum isolation. Choose by asking who pays, who owns the audience, and who answers to regulators for each resource; the pattern falls out of those three answers rather than from the industry label. ## Where to Go from Here Now that you understand the governance model, you can act on it: - [Creating a Sender Profile](/start/guides/creating-a-sender-profile) walks through the dashboard wizard where each inheritance decision is made - [Integrating Sender Profiles into Your Application](/start/guides/integrating-sender-profiles) covers per-profile API keys, customer mapping, and webhook attribution - [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api) automates profile provisioning for tenant onboarding - [Multi-Tenant Architectures](/start/advanced/multi-tenant-architectures) compares account-level alternatives to the profile model - [Roles and Permissions](/reference/api/roles-and-permissions) documents who can manage profiles within an organization ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/sms-encoding-and-length.txt TITLE: SMS Encoding & Message Length ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/sms-encoding-and-length.txt How SMS character encoding (GSM-7 vs Unicode) and message concatenation determine character limits, segment counts, and cost # SMS Encoding & Message Length An SMS is not a free-form text field with a simple character cap. The number of characters that fit in a single message, and how many billable messages a longer text becomes, depends on **which characters you use**. This is a property of the SMS standard itself, not of Sent, and understanding it is the difference between a predictable one-part message and one that silently becomes three. This page explains the mechanics. For how it maps to segment counts, billing, and practical tips on Sent (including a live calculator), see [SMS Length, Segments & Cost](/start/guides/sms-length-and-cost). ## Two encodings Every SMS is encoded one of two ways, decided automatically by the characters in the message body. ### GSM-7 (the default) Standard SMS uses **GSM-7**, a 7-bit alphabet defined by the GSM 03.38 standard. It covers everything most Latin-script messages need: - Upper- and lower-case A–Z and the digits 0–9 - Common punctuation and symbols - A set of accented and non-English Latin characters (`à é ù ì ò Ç Ø Å Δ Φ Ñ Ä Ö Ü §` and more) A message that uses only GSM-7 characters fits **160 characters** in a single SMS. ### The GSM-7 extension table A small set of characters are technically part of GSM-7 but live in an *extension table*. They're sent as an escape character followed by the character itself, so each one **counts as two characters**: ```text \ | ^ € { } [ ] ~ ``` (Plus one non-printable entry, the form-feed control character, for ten in total.) A message like `Balance: {amount}` looks like 17 characters, but `{` and `}` are extension characters that each count as two, so it actually costs 19. It's still a GSM-7 message. A character that *isn't* in GSM-7 at all, such as an arrow (`→`), an emoji, or a curly quote, is a different matter, and brings you to the second encoding. ### Unicode (UCS-2) The moment a message contains **one single character** outside the GSM-7 set, the entire message switches to **Unicode (UCS-2)**, a 16-bit encoding. This includes: - Emoji and other non-GSM symbols (😀, ✅, →, ™) - Non-Latin scripts (Arabic, Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew, Thai, …) - Many "smart" typographic characters that word processors and phones insert automatically: curly quotes (`" "`), en/em dashes (`– —`), and the ellipsis (`…`) A Unicode message fits only **70 characters** in a single SMS. **One emoji re-encodes the whole message.** Unicode applies to the entire body, not just the non-GSM character. Adding a single 😀 to a 100-character message doesn't add one character. It changes the encoding, dropping your limit from 160 to 70 and turning a one-part message into a two-part one. The most common accidental trigger is a curly quote or an em dash pasted in from another app. ## Single message limits | Encoding | Triggered by | Single-message limit | |---|---|---| | **GSM-7** | Latin text within the GSM 03.38 set | **160** characters | | **UCS-2 (Unicode)** | any emoji, non-Latin script, or non-GSM symbol | **70** characters | ## Longer messages: concatenation When a message exceeds the single-message limit, it isn't rejected. Instead, it's split into multiple **segments** that the recipient's phone stitches back together into one message. This is called **concatenation**. Concatenation isn't free of overhead. Each segment carries a small **User Data Header (UDH)** that tells the receiving phone how many parts there are and in what order to reassemble them. That header consumes space that would otherwise hold message content, so **the per-segment limit is lower than the single-message limit**: | Encoding | Single message | Per segment when concatenated | |---|---|---| | **GSM-7** | 160 | **153** | | **UCS-2 (Unicode)** | 70 | **67** | So a 161-character GSM-7 message doesn't split into 160 + 1. It splits into two segments of up to 153 characters each. A 300-character GSM-7 message is 2 segments; a 459-character one (3 × 153) is exactly 3, and a single character more tips it into a fourth. **A GSM-7 extension character can't be split across a segment boundary.** Because characters like `{` or `€` are a two-character escape sequence, if one would land on the boundary between segments it's pushed wholesale into the next segment. In rare cases this means a message sits one character below a limit yet still rolls to an extra segment. ## Template body limit Separately from these SMS-standard limits, Sent enforces a 1,024-character limit on stored template bodies at save time. This is a platform validation rule that applies regardless of encoding, documented with its counting semantics in the [template definition reference](/reference/api/template-definition#content-rules-and-limits). ## Why it matters Three consequences follow directly from these mechanics, and each is covered in the [companion guide](/start/guides/sms-length-and-cost): - **Cost.** Each segment is a separately billed SMS. A message you think of as "one text" can bill as three. - **Predictability.** A template that's comfortably within 160 characters in English can exceed 70 once translated into a non-Latin script, or once a variable expands. - **Deliverability.** More segments mean more parts that must arrive and reassemble correctly; not every network handles long concatenated messages identically. ## Related --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/templates.txt TITLE: Templates ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/templates.txt Why Sent messages are defined as templates, and how one channel-agnostic definition adapts its content, variables, and buttons to SMS, WhatsApp, and RCS # Templates A template in Sent is a reusable, channel-agnostic message definition. It describes what a message says (its text, variables, media, and buttons) once, and the platform adapts that definition to the formatting rules, limits, and approval processes of each channel it is delivered on. Rather than forcing developers to manage channel-specific message formats, templates provide a unified abstraction that automatically adapts to the capabilities and requirements of each messaging platform. ## The Template Abstraction Before template systems, messaging applications faced significant architectural challenges. Developers had to manage the complexity of different messaging channels, each with their own formatting rules, character limits, and approval processes. Templates abstract away the complexity of different messaging channels, providing a unified interface for content creation and management. ### Channel-Agnostic Messaging Templates introduce a **channel-agnostic message definition layer** that describes communication intent and structure without being bound to specific [channel](/start/concepts/channels) implementations: - **Unified Definition Model**: A single template definition automatically generates appropriate content for all supported channels, eliminating the need for channel-specific message creation. - **Rich Content That Degrades Cleanly**: Templates support rich content (media, interactive buttons, structured layouts) that gracefully degrades to simpler formats on channels with limited capabilities. - **Regulatory Compliance**: Templates manage complex approval workflows required by different channels, including WhatsApp (via Meta) and RCS (via your RCS Sender Profile; see [Sender Profiles](/start/concepts/sender-profiles)), without developer intervention. - **Per-Channel Overrides**: Templates support an `rcs` channel override in the body, letting you define separate rich content (rich card layout, carousel, suggestion chips) for RCS while the same template falls back to plain text on SMS. This means a single template ID covers all channels without duplicate templates. ## Template Anatomy Templates use a **declarative content model** with four primary components, each supporting [dynamic content](#dynamic-content): - **Body (required)**: The core message content that every channel must support, including text with embedded variables, links, and media references. On SMS, body length determines how many billable segments the rendered message becomes. See [SMS Encoding & Message Length](/start/concepts/sms-encoding-and-length). - **Header (optional)**: An introduction that can include text or media, adapting to each channel's capabilities. Headers render natively on WhatsApp and are prepended to the message text on RCS; SMS delivers body content only, so headers are omitted there. - **Footer (optional)**: Additional context such as disclaimers or contact information, appended or integrated based on channel formatting capabilities. - **Buttons (optional)**: Interactive elements including quick replies, URL buttons, and custom actions that channels implement according to their interaction models. ### Dynamic Content Templates implement an **entity system** that handles dynamic content insertion with two primary types: - **Dynamic variables** - **Text variables**: Dynamic placeholders with type information (text, number, date, etc.) and sample values for testing. The system ensures type safety and provides meaningful fallbacks when values are missing. - **Media variables**: Image and file references with automatic optimization, CDN delivery, and format adaptation that ensures rich content is delivered appropriately across channels. - **Dynamic links** - URL references with built-in optimization including automatic shortening, click tracking, and channel-appropriate formatting that adapts to channel constraints. ### Content Adaptation Mechanics The template system processes content through sophisticated adaptation: **Structural Transformation**: Templates define ideal message structure that automatically transforms to match each channel's capabilities: full structure for WhatsApp, body-only text for SMS. **Variable Substitution**: Type-aware rendering ensures variables display appropriately for each channel while maintaining business meaning and context. **Interactive Element Conversion**: Buttons render as interactive buttons on WhatsApp and as suggestion chips on RCS (the first four). SMS delivers body content only, so buttons are not delivered there. Essential links belong in the body. ### A Worked Example Consider a shipping notification defined once, with two text variables and a dynamic link in the body, plus a URL button labeled "Track your order": ```text Hi {{0:variable}}, your order {{1:variable}} has shipped. Track it here: {{2:link}} ``` In the template definition, placeholder `0` is declared as a text variable named `customerName` and placeholder `1` as one named `orderNumber`, each with a sample value used for previews and channel approval; placeholder `2` is a link entity carrying the tracking URL. When your app sends the template with `parameters: { "customerName": "Maria", "orderNumber": "#4321" }`, the one definition renders differently per channel: - **On WhatsApp**: "Hi Maria, your order #4321 has shipped. Track it here: …" arrives as a structured template message with the link in the body and a tappable **Track your order** button beneath it. - **On SMS**: the same body arrives as a plain text message. The link in the body still reaches the recipient, but the button is not delivered, because SMS carries body content only. The business meaning is identical on both channels; only the packaging changes, and none of the adaptation logic lives in your app. ## Template Lifecycle Templates implement a **managed lifecycle** that handles complexity and compliance automatically: 1. **Draft state**: New templates begin in draft state, allowing experimentation without impacting production messaging or triggering external approval processes. 2. **Validation and processing**: When published, templates undergo automatic validation, entity processing, and format optimization before becoming available for message sending. 3. **Approval integration**: For channels requiring approval (WhatsApp via Meta, RCS via your RCS Sender Profile), the system automatically submits templates and tracks approval status, managing complex interaction with external platform APIs. ## Templates for Developers Applications should adopt a **template-first approach** by implementing template-driven messaging architectures. Your app should: - **Treat templates as contracts**: Templates define the interface between app logic and message delivery, creating a stable contract that isolates business logic from messaging platform complexity. - **Provide dynamic variables**: Applications provide data to templates rather than constructing messages directly, enabling consistent formatting and automated optimization. - **Implement channel-agnostic operations**: Messaging code references template IDs and provides variable data, while the platform handles channel selection, formatting, and delivery optimization. This allows you to send messages to WhatsApp, SMS, RCS, and other channels with the same codebase. **Success with templates requires adopting specific thinking patterns:** - **Think Intent, Not Channels**: Focus on communication purpose rather than technical delivery methods. - **Design Rich, Degrade Gracefully**: Create rich experiences knowing that automatic adaptation ensures broad compatibility. - **Trust Template Intelligence**: Allow the platform to handle compliance, optimization, and adaptation rather than implementing custom logic. To put this model to work, [Working with Templates](/start/guides/working-with-templates) is the practical starting point for building and approving templates, and the [template definition reference](/reference/api/template-definition) documents every field, limit, and status of the definition JSON. For how routing chooses the channel a template renders on, see [Unified Messaging Intelligence](/start/concepts/unified-messaging). --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/trust-and-safety.txt TITLE: Trust & Safety ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/trust-and-safety.txt How Sent protects your account, your recipients, and the platform from spam, fraud, and abuse. # Trust & Safety Every message you send through Sent passes through a multi-layer protection pipeline before it is dispatched to any carrier or provider. This pipeline exists to protect your recipients, your account, and the platform from spam, fraud, and abuse. None of this requires any configuration on your part. The protections operate automatically on every send request. ## What the pipeline checks Each message is evaluated against a series of independent checks, in order. Apart from account standing, which is enforced when the request arrives, the checks run inside the message pipeline, after the API has accepted your send with `202 Accepted`. A message that fails a check is finalized with a terminal status and reason instead of being dispatched. No provider is contacted, and no charges are incurred. - **Account standing**: Accounts under review or suspension cannot send messages until the issue is resolved. - **Recipient opt-out**: Contacts who have opted out of receiving messages are automatically respected. Sends to opted-out recipients are filtered before dispatch. - **Geographic restrictions**: Sending to certain destinations is restricted based on carrier fraud patterns and regulatory requirements. Your account is provisioned for the countries you declared during onboarding. - **Onboarding compliance**: Accounts that have not yet completed compliance review are subject to sending limits. Completing verification removes these limits. - **Template approval**: Only approved templates may be used for sending. Templates must pass provider review (WhatsApp / TCR) and any applicable platform review before they become active. - **Sender health**: WhatsApp sender numbers are checked against their provider health status before each send. Numbers flagged as blocked by the provider will not be used for dispatch. - **Balance**: A positive account balance is required before any message is dispatched. If the balance is exhausted, the API still accepts the send and each message is finalized as `BLOCKED` instead of being dispatched. New sends go through once you top up. - **Risk screening**: New accounts undergo an automated risk and compliance assessment during onboarding to identify prohibited industries and high-risk patterns before messages are sent at scale. ## How this affects your integration Under normal operating conditions (verified account, positive balance, approved templates, opted-in recipients), you will never encounter these checks. They are invisible. Only request-shape and account-standing problems are rejected synchronously: an unknown template returns `404`, invalid template variables return `400`, and send requests from a suspended account are rejected with `403` and code `BUSINESS_014`. Everything else is decided inside the pipeline after the API accepts the send. A message stopped by a check is finalized with a terminal status and a specific reason code rather than a generic failure. The status appears on `GET /v3/messages/{id}`, its activities, and `message.filtered` / `message.blocked` webhooks: | Check | Final status | Reason code | |-------|--------------|-------------| | Recipient opt-out or suppression list | `FILTERED` | `ERR_CONSENT_BLOCKED` | | Routing rules, including geographic restrictions | `FILTERED` | `ERR_ROUTE_DENIED` | | Insufficient balance | `BLOCKED` | `ERR_INSUFFICIENT_BALANCE` | | Onboarding sending limit reached (KYC stage) | `BLOCKED` | `ERR_KYC_COMPLETED_QUOTA_EXCEEDED` | | Onboarding sending limit reached (channel-setup stage) | `BLOCKED` | `ERR_CHANNEL_SETUP_COMPLETED_QUOTA_EXCEEDED` | | Template not approved for sending | `BLOCKED` | `ERR_TEMPLATE_NOT_APPROVED_FOR_SENDING` | | Light-onboarding template not published | `FAILED` | `ERR_LOB_TEMPLATE_NOT_PUBLISHED` | | Light-onboarding template daily cap reached | `FAILED` | `ERR_LOB_TEMPLATE_DAILY_CAP_REACHED` | | WhatsApp sender health (WABA not approved) | `FAILED` | `ERR_WABA_DEGRADED` | `FILTERED` and `BLOCKED` are policy decisions, not delivery failures, and both are excluded from your deliverability rate. Resending without resolving the underlying issue produces the same outcome. See the [Error Catalog](/reference/api/error-catalog) for full remediation steps. ## Opt-out handling Sent automatically enforces recipient opt-outs. When a contact replies with a recognized opt-out keyword (such as STOP or UNSUBSCRIBE), their record is updated immediately and all future sends to that number are blocked. You can also manage opt-out status programmatically through the Contacts API. An attempt to send to an opted-out contact is still accepted by the API; the message is then finalized as `FILTERED` before any provider is contacted. The contact's preference is always respected. ## Geographic availability Your account is provisioned for specific destination countries based on the information you provide during onboarding. Sending to destinations outside your approved list is blocked to protect against carrier fraud patterns that disproportionately affect certain routes. If your business legitimately needs to reach a destination that is currently unavailable on your account, contact [support@sent.dm](mailto:support@sent.dm). ## Completing verification Accounts that have not yet completed compliance review can send messages up to a limited threshold. Once that threshold is reached, further messages are finalized as `BLOCKED` and your account team will be notified to assist you in completing the process. To ensure uninterrupted sending, complete KYC verification, channel setup, and template approval before going live. The [account setup guide](/start/quickstart/account-setup) walks through each step. ## Getting help If you encounter a protection-layer error that you believe is incorrect, or if your account has been suspended in error, contact [support@sent.dm](mailto:support@sent.dm) with your request ID. Include the full error response. The request ID in the response body allows the support team to trace the exact check that triggered. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/concepts/unified-messaging.txt TITLE: Unified Messaging Intelligence ================================================================================ URL: https://docs.sent.dm/llms/start/concepts/unified-messaging.txt Why Sent exposes one intent-based messaging interface instead of channel APIs, and how the routing engine selects channels, falls back, and stays compliant # Unified Messaging Intelligence Most messaging platforms expose separate APIs for SMS, WhatsApp, RCS, and other channels. That means developers end up writing routing code, handling edge cases, and debugging delivery problems across multiple providers. Sent takes a different approach: instead of giving you channel-specific APIs, Sent gives you a single interface that understands your intent (what you want to send, to whom, and why) and figures out the best way to deliver it. ## The Intelligent Architecture When you integrate directly with channel APIs, you run into the same problems every time: - **Integration Sprawl**: Each channel has different auth, payload formats, error codes, and quirks. - **Business Logic Scattered Everywhere**: Your app logic gets littered with “if WhatsApp, do this; if SMS, do that; if RCS, do something else,” making the code harder to maintain. - **You Own the Routing Logic**: You’re responsible for retries, fallbacks, and figuring out what channels are available for a given phone number as well as which channel is fastest, cheapest, and most reliable. Sent flips that around: you implement messaging into your app once, and the platform handles channel selection, routing, retries, and compliance. ### Imperative vs. Declarative Messaging The two integration styles differ in where the routing knowledge lives. Imperative code encodes channel decisions in your app; declarative code states the intent and leaves the decision to the platform: **Imperative messaging**: ```javascript if (user.hasWhatsApp && templateApproved) { await whatsappAPI.send(whatsappTemplate, user.whatsappId); } else if (user.hasSMS) { await smsAPI.send(smsMessage, user.phoneNumber); } ``` **Declarative messaging with Sent**: ```javascript await sent.sendMessage({ phoneNumber: "+1234567890", templateId: "order_confirmation", templateVariables: { orderId: "12345", total: "$99.99" } }); ``` The imperative version already contains two channel checks and no fallback handling; every new channel, region, or compliance rule adds another branch. The declarative version stays the same size as those concerns grow, because they are absorbed by the platform. The [Sending Messages guide](/start/guides/sending-messages) shows the declarative call in full, including how to pin a channel explicitly when you need to. Messaging complexity shouldn’t live in your codebase. Sent centralizes it as infrastructure: - **Absorbed Complexity**: Multi-channel routing, compliance, and optimization handled once, not by every app. - **Centralized Expertise**: Sent maintains the industry rules, carrier quirks, and provider integrations. - **Faster Innovation**: New features and integrations roll out at the platform level. **Sent chooses the channel, adapts content, handles fallbacks.** This way you can focus on your business logic instead of channel quirks. ## The Decision Engine ### Real-Time Routing Every message you send through Sent is resolved by a routing engine at send time, after the API has accepted the request. For each recipient, the engine weighs: * **The requested channel**: a pinned channel (`sms`, `whatsapp`, `rcs`) restricts routing to that channel; the default auto-detect channel lets the engine choose. * **Routing rules**: rules matched on the recipient (country, number prefix, carrier, number type), the sender, the template, and the channel. Account-scoped rules take precedence over global ones, and more specific matches over less specific ones. * **Channel capability**: whether the recipient is registered on WhatsApp, whether the device supports RCS, and whether the template is approved on each candidate channel. * **Compliance gates**: recipient consent and account preconditions are checked before any route is attempted. The matching rules form an ordered candidate list: the first candidate wins and the message dispatches on it, while the remaining candidates are held as fallback routes. There is no fixed channel preference order: the winner depends on which routes exist for your account and the recipient. The factors that decide among viable channels are summarized in [Routing Factors](/start/concepts/channels#routing-factors), and the exact matching and ordering rules are specified in the [channel routing reference](/reference/channel-routing). ### Fallback When a route fails, Sent walks the fallback candidates that routing already resolved instead of giving up on the message: **How the fallback path behaves:** * **Failed submission to a provider** → the next candidate route is attempted, when the matched rule allows fallback. That next route may be the same channel through a different provider, or a different channel on auto-detect sends. * **Accepted, then reported as failed** → the message re-enters routing only when the failure is one a different route might overcome, such as a provider outage, timeout, or a WhatsApp recipient-side failure (the path behind WhatsApp-to-SMS fallback). Routes already attempted are excluded, and a message attempts at most 3 distinct routes. * **Permanent failures** (invalid number, opted-out recipient) → the send stops and the terminal status is reported. Consent is re-checked on every reroute, so an opted-out recipient never receives a fallback attempt. Each attempt and the terminal outcome surface as status webhooks. See [Message Status Tracking](/start/guides/message-status-tracking) for observing this lifecycle from your app. ### Multi-Provider Orchestration Sent plugs into multiple carriers and APIs, normalizing the differences: - **Provider Abstraction**: One interface to SMS providers, WhatsApp Business API, RCS providers, and others. - **Health Monitoring**: Real-time checks on provider reliability and limits. - **Load Balancing**: Spreads traffic across providers for cost, capacity, and performance. ### Regulatory Compliance **Built-In Regulatory Compliance Logic**: - **Global Rules**: Sent tracks country-specific restrictions and applies them at send time. - **Consent Enforcement**: Opt-ins/outs applied consistently across all channels. - **Content Validation**: Automatic checks against carrier and platform rules. **Template Approvals (WhatsApp)**: - **Submission Automation**: Templates formatted and submitted through each channel's approval flow automatically: Meta for WhatsApp, carrier verification for RCS. - **Status Tracking**: Approval state synced back into your system. - **Retry Logic**: Failed approvals reworked and resubmitted. Compliance is baked into the platform. No chasing new regulations or patching code every time rules change. ### Always Learning The routing engine learns from real delivery data: * **Pattern recognition** → what works best by region, channel, time * **Anomaly detection** → auto-adapts to outages and disruptions * **Route tuning** → balances cost, reliability, and engagement ### Network Effects Because all apps share the same intelligence layer, everyone benefits: * **Shared learnings** → improve delivery everywhere * **Regional insights** → drive smarter defaults * **Carrier relationships** → are used at platform scale ## Template-Driven Intelligence ### Templates as Config, Not Hardcoded Messages Templates let you describe intent (“confirm order,” “welcome user”) without worrying about formatting differences between SMS, WhatsApp, and RCS. Sent adapts them automatically. For RCS, templates can include an `rcs` channel override that defines rich card layouts, suggestion chips, and carousel cards, while the same template ID delivers plain text on SMS: * **Built-in compliance** (WhatsApp approval, SMS opt-in, regional rules) * **Rich formats where the channel supports them** (plain text everywhere else) How this adaptation works is the subject of the [Templates concept page](/start/concepts/templates); [Working with Templates](/start/guides/working-with-templates) shows how to build and publish one. ### Variables for Dynamic Content * **Type-safe**: Variables know their types and adapt across channels * **Context-preserving**: Business meaning stays intact while formatting adjusts * **Fallback values**: Missing/invalid variables degrade gracefully ## Built for Change Messaging isn’t static. New channels, new compliance rules, new algorithms. Sent’s architecture is designed to absorb this without you rewriting your code: * **Plug-in new channels** without changing your app * **Routing algorithms auto-upgrade** as Sent improves them * **Compliance handled at the platform level**, so you stay safe without extra work Sent’s unified messaging intelligence removes multi-channel complexity from your codebase by abstracting away provider APIs and routing logic. You express intent, Sent handles delivery. As channels, carriers, and provider APIs change, Sent absorbs the change, so your app doesn’t need constant rewrites. The trade-off is the one you accept with any managed abstraction, whether a payments API or a container orchestrator: you give up direct control over per-channel mechanics in exchange for one consistent interface that hides provider complexity. For most applications that trade is worth making, because routing rules, compliance requirements, and provider integrations change faster than product code should. When you do need direct control, you can pin a channel explicitly in the send request. The value here isn’t just integration simplicity. Routing rules, provider health handling, and compliance gates are maintained at the platform level, so each application benefits from work no single app team would build on its own. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/api-keys.txt TITLE: Creating and Managing API Keys ================================================================================ URL: https://docs.sent.dm/llms/start/guides/api-keys.txt How to create a Sent API key in the dashboard, store it safely in environment variables, verify it authenticates, and rotate or revoke keys independently. # Creating and Managing API Keys This guide shows you how to create a Sent API key, store it securely, verify that it authenticates, and rotate or revoke it. It assumes you have a Sent account with the Owner, Admin, or Developer role; these roles can open the API Keys page. ## Create an API key 1. In the [Sent Dashboard](https://app.sent.dm), open **Development → API Keys** in the sidebar, or go directly to the [API Keys page](https://app.sent.dm/dashboard/api-keys). 2. Select **Add API Key**. 3. Name the key after where it will be used (for example, `production-backend`), then select **Create Key**. The key appears in the table with its value masked. Use the copy control on the key value to copy the full key. If you deploy to more than one environment, create a separate key for each (development, staging, production). All keys share the same UUID format; separate keys let you rotate or revoke one environment without touching the others. The page header also displays your `x-sender-id`. Only legacy v1/v2 endpoints need it; v3 requests authenticate with the API key alone. ## Store the key securely Keep the key out of source code. Load it from an environment variable: ```bash # .env — add this file to .gitignore SENT_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` - Never commit keys to version control; a key in repository history stays exposed even after you delete it from the working tree. - Never ship a key to a browser or mobile app. Call the API from your backend only. - If each of your customers has their own Sent account and supplies their own key, build a per-request client instead of reading one key from the environment. See the integration blueprint's [per-request authentication pattern](/build/authentication). ## Verify the key authenticates Call the account endpoint with the new key: ```bash curl https://api.sent.dm/v3/me \ -H "x-api-key: $SENT_API_KEY" ``` A `200` response with your account profile confirms the key works. If you get a `401` with error code `AUTH_002`: - Confirm you copied the full key and the header value is not empty (`echo $SENT_API_KEY`). - Check the key's **Status** in the API Keys table; a disabled key is rejected. - Confirm you are using the key for the intended environment. If you retry a failing key repeatedly, 10 consecutive failures temporarily lock that credential and the API returns `429`. Wait for the `Retry-After` period, then retry with a valid key. See the [Authentication reference](/reference/api/authentication) for the exact error responses. ## Rotate a key To rotate without downtime: 1. Create a replacement key as described in [Create an API key](#create-an-api-key). 2. Update `SENT_API_KEY` in your environment or secrets manager and redeploy. 3. Verify the new key with the `/v3/me` call from the previous section. 4. In the API Keys table, disable or delete the old key from the **Actions** column. If a key is compromised, invert the order: delete the compromised key immediately, then deploy the replacement. Requests fail with `401` until the new key is live, which is preferable to leaving a leaked key active. ## Related pages - [Authentication reference](/reference/api/authentication): the `x-api-key` header, response headers, and AUTH error codes. - [API authentication](/start/concepts/api-authentication): why Sent authenticates with a single header-based key. - [Rate limits](/reference/api/rate-limits): per-account request limits. ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/batch-operations.txt TITLE: Batch Operations ================================================================================ URL: https://docs.sent.dm/llms/start/guides/batch-operations.txt How to send messages in bulk with the Sent API: batches of up to 1000 recipients, rate limit management, idempotent retries, and bulk contact imports. # Batch Operations This guide shows you how to send high-volume campaigns and import contacts in bulk while staying inside Sent's API limits. ## Overview When dealing with large volumes: - **Batch message sending** - Send to up to 1000 recipients in one request - **Bulk contact import** - Import thousands of contacts efficiently - **Rate limit management** - Stay within API limits - **Queue-based processing** - Process large jobs asynchronously ### Large-Scale Sending Flow ## Batch Message Sending ### Multiple Recipients Send the same message to up to 1000 recipients in one request: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": [ "+1234567890", "+1987654321", "+1555555555" ], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "announcement": "New feature launched!" } }, "channel": ["sms", "whatsapp", "rcs"] }' ``` ```typescript const response = await client.messages.send({ to: [ '+1234567890', '+1987654321', '+1555555555' // Up to 1000 recipients ], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', parameters: { announcement: 'New feature launched!' } } }); // Track individual message IDs const messageIds = response.data.recipients.map(r => r.message_id); console.log(`Sent ${messageIds.length} messages`); ``` ```python response = client.messages.send( to=[ "+1234567890", "+1987654321", "+1555555555" # Up to 1000 recipients ], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "announcement": "New feature launched!" } } ) message_ids = [r.message_id for r in response.data.recipients] print(f"Sent {len(message_ids)} messages") ``` ```go response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{ "+1234567890", "+1987654321", "+1555555555", // Up to 1000 recipients }, Channel: []string{"sms", "whatsapp", "rcs"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Parameters: map[string]interface{}{ "announcement": "New feature launched!", }, }, }) // Track individual message IDs messageIDs := make([]string, len(response.Data.Recipients)) for i, r := range response.Data.Recipients { messageIDs[i] = r.MessageID } fmt.Printf("Sent %d messages\n", len(messageIDs)) ``` ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addTo("+1987654321") .addTo("+1555555555") .addChannel("sms") .addChannel("whatsapp") .addChannel("rcs") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("announcement", JsonValue.from("New feature launched!")) .build()) .build()) .build(); var response = client.messages().send(params); // Track individual message IDs List messageIds = response.data().recipients().stream() .map(r -> r.messageId()) .toList(); System.out.println("Sent " + messageIds.size() + " messages"); ``` ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890", "+1987654321", "+1555555555" // Up to 1000 recipients }, Channels = new List { "sms", "whatsapp", "rcs" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Parameters = new Dictionary { { "announcement", "New feature launched!" } } } }; var response = await client.Messages.Send(parameters); // Track individual message IDs var messageIds = response.Data.Recipients.Select(r => r.MessageId).ToList(); Console.WriteLine($"Sent {messageIds.Count} messages"); ``` ```php $result = $client->messages->send( to: [ '+1234567890', '+1987654321', '+1555555555' // Up to 1000 recipients ], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'parameters' => [ 'announcement' => 'New feature launched!' ] ], channels: ['sms', 'whatsapp', 'rcs'] ); // Track individual message IDs $message_ids = array_map(fn($r) => $r->message_id, $result->data->recipients); echo "Sent " . count($message_ids) . " messages\n"; ``` ```ruby result = sent_dm.messages.send( to: [ "+1234567890", "+1987654321", "+1555555555" # Up to 1000 recipients ], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", parameters: { announcement: "New feature launched!" } }, channels: ["sms", "whatsapp", "rcs"] ) # Track individual message IDs message_ids = result.data.recipients.map(&:message_id) puts "Sent #{message_ids.length} messages" ``` ### Batch Response ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "product_announcement", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+1234567890", "channel": "sms" }, { "message_id": "8ba7b831-9dad-11d1-80b4-00c04fd430c8", "to": "+1987654321", "channel": "whatsapp" }, { "message_id": "8ba7b832-9dad-11d1-80b4-00c04fd430c8", "to": "+1555555555", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_batch_001", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` Batch requests count against rate limits per request (not per recipient). Each `POST /v3/messages` call counts as one request toward the 200 req/min limit, regardless of how many recipients are included. ## Large-Scale Sending (10,000+ Recipients) For campaigns with tens of thousands of recipients, process the list in slices of 1000 with a delay between batches: ```typescript // batchProcessor.ts const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function sendCampaign(recipients: string[], templateId: string) { const BATCH_SIZE = 1000; // Maximum recipients per request const DELAY_BETWEEN_BATCHES = 1000; // 1 second const results = { sent: 0, failed: 0, messageIds: [] }; // Process in batches for (let i = 0; i < recipients.length; i += BATCH_SIZE) { const batch = recipients.slice(i, i + BATCH_SIZE); try { const response = await client.messages.send({ to: batch, template: { id: templateId } }); // The 202 response confirms acceptance; track delivery via webhooks results.sent += response.data.recipients.length; results.messageIds.push(...response.data.recipients.map(r => r.message_id)); console.log(`Batch ${i / BATCH_SIZE + 1} complete: ${batch.length} messages`); // Rate limiting delay (except for last batch) if (i + BATCH_SIZE < recipients.length) { await sleep(DELAY_BETWEEN_BATCHES); } } catch (error) { console.error(`Batch ${i / BATCH_SIZE + 1} failed:`, error); results.failed += batch.length; } } return results; } // Usage const recipients = await getAllCustomerPhoneNumbers(); // 5000 numbers const results = await sendCampaign(recipients, '7ba7b820-9dad-11d1-80b4-00c04fd430c8'); console.log(`Campaign complete: ${results.sent} sent, ${results.failed} failed`); ``` ## Bulk Contact Import ### CSV Import Format your CSV file: ```csv phone_number +1234567890 +1987654321 +1555555555 ``` Import script: ```typescript import { parse } from 'csv-parse'; import fs from 'fs'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function importContacts(csvPath: string) { const parser = fs.createReadStream(csvPath).pipe(parse({ columns: true, skip_empty_lines: true })); const results = { created: 0, failed: 0, errors: [] }; const BATCH_SIZE = 50; let batch = []; for await (const record of parser) { batch.push({ phoneNumber: record.phone_number }); if (batch.length >= BATCH_SIZE) { const result = await processBatch(batch); results.created += result.created; results.failed += result.failed; results.errors.push(...result.errors); batch = []; // Rate limit protection await sleep(100); } } // Process remaining if (batch.length > 0) { const result = await processBatch(batch); results.created += result.created; results.failed += result.failed; } return results; } async function processBatch(batch: any[]) { const results = { created: 0, failed: 0, errors: [] }; await Promise.all(batch.map(async (contact) => { try { await client.contacts.create(contact); results.created++; } catch (error) { results.failed++; results.errors.push({ contact, error: error.message }); } })); return results; } ``` ## Rate Limit Management ### Understanding Limits | Endpoint | Limit | Window | |----------|-------|--------| | `POST /v3/messages` | 200 requests | 1 minute | | `POST /v3/contacts` | 200 requests | 1 minute | | `GET /v3/*` | 200 requests | 1 minute | Limits count per request, not per recipient, and all API keys on an account share one pool. A stricter tier of 10 requests per minute applies only to two sensitive webhook endpoints, `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test`, neither of which is involved in batch sending. Refer to the [Rate Limits reference](/reference/api/rate-limits) for the full per-endpoint table and rate limit headers. ### Staying Under the Limit Space your batch requests so total throughput stays below 200 requests per minute; the one-second delay between batches in the [Large-Scale Sending](#large-scale-sending-10000-recipients) loop keeps you at a safe 60 requests per minute. If you need finer-grained control, refer to the [Rate Limits reference](/reference/api/rate-limits#implement-request-throttling) for ready-made throttle and backoff implementations in TypeScript, Python, and Go. ### Handling Rate Limit Errors If a request returns `429`, honor the `Retry-After` header before retrying: ```typescript const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function sendWithBackoff(phoneNumber: string, templateId: string, maxRetries = 3) { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await client.messages.send({ to: [phoneNumber], template: { id: templateId } }); } catch (error) { if (error.status === 429) { // Get retry-after header or use exponential backoff const retryAfter = error.headers['retry-after'] || Math.pow(2, attempt); console.log(`Rate limited. Waiting ${retryAfter} seconds...`); await sleep(retryAfter * 1000); continue; } throw error; } } throw new Error('Max retries exceeded'); } ``` ## Queue-Based Processing For sustained high volume, run sends through a persistent job queue (for example BullMQ, Sidekiq, or Celery) instead of a single in-process loop. The queue and worker mechanics belong to your infrastructure; the Sent-specific requirements are: - **One job per batch** - Group up to 1000 recipients per job so each job makes a single `POST /v3/messages` call. - **Bounded concurrency** - Cap workers so combined throughput stays under 200 requests per minute. - **Idempotent jobs** - Set an idempotency key per job so queue retries don't double-send; see [Retry Safely with Idempotency Keys](#retry-safely-with-idempotency-keys). - **Persist message IDs** - Store the `message_id` values from each `202` response so webhook events can be matched back to jobs. - **Watch queue depth** - Alert when jobs accumulate faster than workers drain them. Reuse a single SDK client instance across jobs so HTTP connections are pooled. ## Monitoring Bulk Operations Log progress per batch from the counts in each `202` response, as the [Large-Scale Sending](#large-scale-sending-10000-recipients) loop does. For delivery outcomes, subscribe to the webhook events `message.delivered`, `message.failed`, `message.filtered`, and `message.blocked` instead of polling `GET /v3/messages/{id}` for every message; see [Status Tracking](/start/guides/message-status-tracking). ## Best Practices ### Handle Partial Failures A `202` response confirms acceptance, not delivery. Validation is all-or-nothing (a `400` rejects the whole request), so every recipient in an accepted batch gets a `message_id`, but individual messages can still end `FAILED`, `FILTERED` (recipient opted out), or `BLOCKED` (insufficient balance) asynchronously. Persist the accepted IDs and reconcile them against webhook events: ```typescript const recipients = ['+14155551234', '+14155555678']; const response = await client.messages.send({ to: recipients, template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' } }); // Persist each accepted message so your webhook handler can mark it // DELIVERED, FAILED, FILTERED, or BLOCKED as events arrive const accepted = response.data.recipients.map((r) => ({ messageId: r.message_id, to: r.to, status: 'PENDING' })); console.log(`Accepted ${accepted.length} messages`); ``` ### Retry Safely with Idempotency Keys Give every batch a deterministic idempotency key so a retried request (after a timeout, `429`, or worker crash) replays the original response instead of double-sending: ```typescript const recipients = ['+14155551234', '+14155555678']; const campaignId = 'spring_launch'; const batchNumber = 1; const response = await client.messages.send( { to: recipients, template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' } }, { idempotencyKey: `campaign_${campaignId}_${batchNumber}` } ); ``` Refer to the [Idempotency reference](/reference/api/idempotency) for key format and replay behavior. ### Start Small Before launching a full campaign, run the batch pipeline against a small slice, for example `sendCampaign(recipients.slice(0, 10), templateId)`, and confirm delivery through webhooks before committing the full list. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/connect-whatsapp.txt TITLE: Connect a WhatsApp Business Account (WABA) to Sent ================================================================================ URL: https://docs.sent.dm/llms/start/guides/connect-whatsapp.txt Connect a WhatsApp Business Account to Sent: Meta authorization, choosing the sending number, confirming the channel is live, and the Sender Profile path. # Connect a WhatsApp Business Account (WABA) to Sent This guide shows you how to connect a WhatsApp Business Account (WABA) to Sent and confirm that the channel can send. It assumes you administer a Meta Business Account and that your Sent account has cleared [KYC review](/start/quickstart/account-setup). If you are working through initial setup of all three channels, this step sits inside [Channel Setup](/start/quickstart/channel-setup) alongside your SMS number, RCS, and billing. ## Before you start Confirm all four requirements. The flow fails at a different stage for each one you skip. - **A verified Meta Business Account.** Meta's business verification is separate from Sent's KYC review, and both must pass before Meta lets you share a WABA with a partner. - **A Facebook personal account with administrator access** to that Meta Business Account. Meta blocks the authorization step for non-administrators. - **A phone number that is not linked to another WhatsApp Business Account.** A number can belong to one WABA at a time, including numbers registered with the WhatsApp Business app. - **A Sent account with KYC approved.** Sent locks channel configuration until compliance review clears. For the long form of each requirement, see [Prerequisites for WhatsApp setup](/troubleshooting/whatsapp-setup#prerequisites-for-whatsapp-setup). **Open the WhatsApp connection flow** If your account is still in onboarding, open the [Sent dashboard](https://app.sent.dm/dashboard), select **Continue Channel Setup**, and advance to the WhatsApp step of the wizard. If your account is already onboarded, open [Channels](https://app.sent.dm/dashboard/channels), switch to the **WhatsApp** tab, and select **Connect Account** under Account Management. The same dialog reads **Switch Account** when a WABA is already connected. **Authorize Sent from the Meta window** Select **Log in with Meta**. Sent opens Meta's signup window, where you log in with your Facebook account, pick an existing WhatsApp Business Account or create one, and grant Sent permission to manage messages on it. The button stays inactive until Meta's script finishes loading, and the authorization window is a pop-up. Ad blockers and privacy extensions are the most common cause of a failed authorization: they block Meta's script, so the button never activates or the pop-up never opens. Use a browser profile without extensions, or an incognito window, and allow pop-ups from `sent.dm` and `facebook.com`. Chrome is the most reliable choice, because Safari's tracking prevention interferes with Meta's login flow. **Choose the sending number** After authorization, Sent reads the numbers on the WABA and asks which one sends your WhatsApp messages. Which options appear depends on what the WABA and your Sent account already hold: | Option | When it appears | What it does | | :--- | :--- | :--- | | **Existing Numbers** | The WABA already has numbers registered | Sends from a number already on the WABA | | **New Number** | Your Sent account has a number that is not yet on the WABA | Registers your Sent number with the WABA | | **Use your own phone number** | Onboarding wizard, when neither your Sent account nor the WABA has a number | Registers a number you enter. Meta sends it a verification code, which can take a few minutes to arrive | | **Get a provisioned number** | Channels dialog, when neither your Sent account nor the WABA has a number | Allocates a number from Sent and registers it with the WABA | If your Sent number is already registered on the WABA, the dialog selects it for you. To keep one identity across SMS, WhatsApp, and RCS, send from the number you already use for SMS: recipients see it on every channel. If code verification does not complete, select **Verify Later**. The number is attached and the connection saves, but WhatsApp sending stays unavailable until the number reaches `Connected`. A WABA holds a limited number of phone numbers. When it is full, the dialog says so, and you have to remove a number from the WABA before adding another. **Save the connection** Select **Continue**. Sent stores the WABA ID, business name, business owner, and access token against your account, then subscribes the WABA to Meta's webhook notifications so sent, delivered, and read updates reach your account. A success message names the business you connected. Switching an existing connection to a *different* WABA prompts you to move templates. **Confirm** resubmits your templates to the new WABA; **Delete** removes them. Resubmitted templates go through Meta approval again, so time a switch away from a running campaign. **Verify the channel is live** In the dashboard, [Channels](https://app.sent.dm/dashboard/channels) → **WhatsApp** now shows the Facebook Business Owner, WhatsApp Business Name, Phone Number, and Business ID. Two indicators tell you whether the channel can actually send: - **Phone number status**, next to the number. `Connected` means the number is registered and ready. `Pending` means registration has not finished. `Flagged`, `Restricted`, `Banned`, `Disconnected`, and `Offline` each carry an explanation in the dashboard. - **Messaging status**, which appears only when something is wrong. `limited` or `blocked` lists the issues Meta reports, each with its suggested fix, and links to the WABA in Meta Business Manager. From the API, `GET /v3/me` reports the same connection: ```bash curl "https://api.sent.dm/v3/me" \ -H "x-api-key: $SENT_API_KEY" ``` A connected channel returns `configured: true` with the number and business name: ```json { "success": true, "data": { "channels": { "whatsapp": { "configured": true, "phone_number": "+14155550100", "business_name": "Acme Corporation" } } } } ``` Refer to the [get account reference](/reference/api/accounts/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint) for the rest of the response. ## Connect a WABA to a Sender Profile Sub-accounts created through the API take a different path: profiles inherit the organization's WABA by default, and you pass credentials only when a profile needs its own. Supply them on `POST /v3/profiles`: ```bash curl -X POST "https://api.sent.dm/v3/profiles" \ -H "x-api-key: $ORG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Sales Team", "whatsapp_business_account": { "waba_id": "123456789012345", "phone_number_id": "987654321098765", "access_token": "EAAxxxxxxxxxxxxxxx" } }' ``` - `waba_id` and `access_token` are required. Create the token as a Meta Business Manager System User with the `whatsapp_business_messaging` and `whatsapp_business_management` permissions. Sent stores it and never returns it in a response. - `phone_number_id` is optional. Provide it to use a number already registered under that WABA, or omit it to have a number provisioned and registered with the WABA during setup. - Omit `whatsapp_business_account` entirely to inherit the organization's WABA. The request fails with `422` if the organization has not completed the Meta signup flow itself. The [sub-account profiles guide](/start/advanced/sub-account-profiles-api) covers the full create-and-complete sequence, and the [create profile reference](/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint) lists every field. ## Before your first WhatsApp message - **Send from an approved template.** WhatsApp requires an approved template for business-initiated messages. [Create a template in the dashboard](/start/guides/create-a-template), then send it with [your first message](/start/quickstart/first-message). - **Expect your preview templates to disappear.** Completing WABA setup removes the light-onboarding sample templates, which were never submitted to Meta, and submits your auto-reply templates to the new WABA for approval. - **Watch the onboarding send cap.** Accounts that have finished channel setup but not the remaining compliance steps hit a stage send limit, after which messages are blocked with `ERR_CHANNEL_SETUP_COMPLETED_QUOTA_EXCEEDED`. See [Trust & Safety](/start/concepts/trust-and-safety#how-this-affects-your-integration). ## If a stage fails | Symptom | Stage | Where to fix it | | :--- | :--- | :--- | | **Log in with Meta** stays greyed out, or no Meta window opens | Authorize | [Browser extensions block the Meta script](/troubleshooting/whatsapp-setup#log-in-with-meta-button-is-greyed-out-or-pop-up-does-not-open) | | "The information could not be verified" after you authorize | Authorize | [Meta business verification and browser fixes](/troubleshooting/whatsapp-setup#the-information-could-not-be-verified-connection-error) | | Meta reports that your account lacks permission | Authorize | [Missing administrator permissions](/troubleshooting/whatsapp-setup#missing-administrator-permissions-for-waba-connection) | | "This phone number is already used and linked to a WhatsApp account" | Choose a number | [Release the number from its current WABA](/troubleshooting/whatsapp-setup#this-phone-number-is-already-used-and-linked-to-a-whatsapp-account) | | Meta sends a verification code to a number you cannot access | Choose a number | [Verification on Sent-provisioned numbers](/troubleshooting/whatsapp-setup#meta-verification-code-sent-to-sent-provisioned-number) | | Your country or number is no longer offered | Choose a number | [Country or number became unavailable](/troubleshooting/whatsapp-setup#country-or-number-became-unavailable-after-registration) | | Messages fail after the channel connects | Sending | [WhatsApp delivery failures by error code](/troubleshooting/whatsapp-delivery-errors) | ## Related - [WhatsApp onboarding troubleshooting](/troubleshooting/whatsapp-setup): every known Meta-side connection failure and its resolution. - [Channels](/start/concepts/channels): how Sent picks between SMS, RCS, and WhatsApp per message. - [Channel Setup](/start/quickstart/channel-setup): the surrounding onboarding steps, including your SMS number, RCS, and billing. - [WhatsApp template issues](/troubleshooting/template-issues): template creation, approval, and rejection on a connected WABA. ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/create-a-template.txt TITLE: Create a Template in the Dashboard ================================================================================ URL: https://docs.sent.dm/llms/start/guides/create-a-template.txt Step-by-step directions for building a message template in the Sent dashboard: header, body, dynamic variables, buttons, category, and review submission. # Create a Template in the Dashboard This guide shows you how to build a custom message template in the Sent dashboard and submit it for review. It assumes your account has completed [account setup](/start/quickstart/account-setup), because template creation requires a fully onboarded account. If you have never created a template, start with the [first template quickstart](/start/quickstart/first-template); for background on how templates adapt content per channel, see [how templates work](/start/concepts/templates). **Open the template builder** In the dashboard, open [Templates](https://app.sent.dm/dashboard/templates) and create a new template. The builder shows a live per-channel preview as you edit. **Add a header (optional)** Headers appear on WhatsApp and RCS only: WhatsApp renders a native header, RCS prepends the text to the message body. A header holds up to 60 characters and at most 1 dynamic variable. Text headers cannot contain emojis, newlines, or formatting markup (`*`, `_`, `~`).
**Write the body** The body is the only required component and the only one that reaches SMS as well as WhatsApp and RCS. It holds up to 1024 characters. To personalize it, click **Insert dynamic variable** or **Insert dynamic link**. Variables render as yellow boxes and links as light-blue boxes. | Dynamic Variable Type | Description | | :--- | :--- | | Text | Plain text content that can be personalized | | Link | Dynamic URLs for tracking or personalization | | Image/Media | Images or media files (WhatsApp only) | | File | Document files (WhatsApp only) | Give every variable a descriptive name (`customerName`, `orderNumber`) and a realistic sample value: reviewers see the sample when they evaluate the template. If you want different wording per channel, the definition supports `sms`, `whatsapp`, and `rcs` body overrides alongside the multi-channel body; refer to the [template definition reference](/reference/api/template-definition#body-object). **SMS message length depends on encoding.** A plain-text (GSM-7) SMS fits 160 characters; a message containing any emoji or non-Latin character encodes as Unicode and fits only 70. Longer bodies are split into billed segments (153 or 67 characters each). Remember that a variable can push a template over a limit at send time. See [SMS Encoding & Message Length](/start/concepts/sms-encoding-and-length) for the full rules and [SMS Length, Segments & Cost](/start/guides/sms-length-and-cost) for a live calculator.
**Add a footer (optional)** Footers appear on WhatsApp and RCS only: WhatsApp renders a native footer, RCS appends the text to the message body. A footer holds up to 60 characters and cannot contain variables, emojis, newlines, or formatting markup.
**Add buttons (optional)** Buttons appear on WhatsApp and RCS: WhatsApp renders interactive buttons, RCS shows the first four as suggestion chips. Click **Add button** and pick a type. A template holds up to 10 buttons, and button labels hold up to 25 characters. | Button Type | Quantity allowed | Description | | :--- | :--- | :--- | | Custom Buttons | Up to 10 | Custom quick replies and preconfigured responses | | Visit Website Buttons | Up to 2 | Static URLs, or dynamic URLs with exactly one variable at the end | | Phone Number Button | Up to 1 | One-tap calling with country code and phone number | | Copy Offer Button | Up to 1 | One-tap copying for OTP or offer codes | Place the most important action first: on RCS only the first four buttons render, so order decides what users see.
**Set the category and language** Both fields are detected automatically from the template content; override them manually if the detection is wrong. Choose the category (MARKETING, UTILITY, or AUTHENTICATION) that matches the message's real intent. Meta re-categorizes mismatched WhatsApp templates during review, so an honest category speeds approval. **Review the JSON** Click **View JSON** to see the template's definition: the same JSON you send in the `definition` field when you [create a template via the API](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint) with `POST /v3/templates`. Refer to the [template definition reference](/reference/api/template-definition) for every field and limit. **Save as draft or submit for review** To keep editing and testing, click **Save as Draft**. Drafts cannot be sent on any channel. To start approval, click **Submit for Review**: Meta reviews WhatsApp templates (typically 24–48 hours); when no WhatsApp Business Account is connected, Sent's compliance team reviews the template per channel. See [how template approval works](/start/guides/working-with-templates#how-template-approval-works). If you create templates via the API instead, the same choice is the `submit_for_review` field on `POST /v3/templates`: it defaults to `false` (saved as a draft), and `true` submits the template for review immediately after creation. **Verify and copy the template ID** The new template appears in your [templates list](https://app.sent.dm/dashboard/templates) with status `DRAFT` if saved, or `PENDING` while review is in progress. Copy its template ID; you pass it as the template `id` when [sending messages](/start/guides/sending-messages). Store it in your own database; it is the stable key for this template across all channels. To track approval automatically, subscribe to `templates` [webhook events](/start/webhooks/event-types); each event carries the template ID, channel, and new status. ## Troubleshooting | Symptom | Likely cause | Fix | | :--- | :--- | :--- | | The builder rejects the body | A content rule failed: bodies cannot start or end with a newline, place variables back-to-back, or contain more than two consecutive line breaks | Adjust the text; the full rules are in the [content rules reference](/reference/api/template-definition#content-rules-and-limits) | | Template stuck in `PENDING` | Meta review is still in progress (typically 24–48 hours) | See [template troubleshooting](/troubleshooting/template-issues) | | Template `REJECTED` | The reviewer declined the content | Revise the template and resubmit; the `templates` webhook event carries the rejection reason in its `reason` field | ## Related - Refer to the [template definition reference](/reference/api/template-definition) for a full list of fields, limits, and statuses. - [Sending Messages](/start/guides/sending-messages): send with your approved template. - [Working with Templates](/start/guides/working-with-templates): overview of all template documentation. ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/creating-a-sender-profile.txt TITLE: Creating a Sender Profile ================================================================================ URL: https://docs.sent.dm/llms/start/guides/creating-a-sender-profile.txt Create a Sender Profile in the Sent dashboard: identity, destination countries, sender number, WhatsApp, sharing, billing, and TCR compliance settings. # Creating a Sender Profile This guide shows you how to create a Sender Profile in the Sent dashboard and confirm it is ready to send. It assumes your account belongs to an organization and holds the **ADMIN** role; only organization administrators can view and manage Sender Profiles. For what Sender Profiles are and how resource inheritance works, see [Sender Profiles](/start/concepts/sender-profiles). To provision profiles programmatically instead, see [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api). ## Create the profile The wizard walks through eight steps. Your choices in the sharing, billing, and compliance steps map directly to the [resource configuration patterns](/start/concepts/sender-profiles#resource-configuration-patterns); you can edit a profile later from the Profiles page if a decision changes. ### Open the Profiles page In the dashboard, go to **Profiles** and select **+ Create Sender Profile**. ### Enter basic information Give the profile a display name, a short name, and a description. The short name is 3 to 11 characters (letters, numbers, and spaces, with at least one letter) and is used as the sender ID for SMS, so pick the name recipients should see. ### Select destination countries Select every country you plan to message from this profile. If you send SMS to the United States (+1), plan for carrier registration: US mainland traffic is subject to 10DLC, which adds review time before US sending starts. See [10DLC Registration](/start/advanced/10dlc-registration) for the process and timelines. ### Choose the sender number Select the primary phone number the profile sends from. You can add more numbers later. ### Connect WhatsApp Business If the profile should send on WhatsApp, select **Log in with Meta** and connect an existing or a new WhatsApp Business account. ### Configure contact and template sharing Decide how the profile exchanges contacts and message templates with the rest of the organization: - To give the profile access to the organization's existing contacts or templates, enable the **Inherit organization** toggles. - To let other profiles in the organization use this profile's contacts or templates, enable the **Share** toggles. Shared contacts are visible to every profile that inherits from the organization. Leave **Share Contacts** off when tenants or clients must not see each other's customer data. ### Choose a billing type Pick who pays for the profile's traffic: - **Use organization billing**: charges flow to the organization's payment methods. - **Use profile billing**: the profile has its own payment methods and invoices. - **Mixed**: inherit the organization's billing now, with the ability to switch the profile to its own billing later. ### Configure TCR compliance For US SMS traffic, choose how the profile registers with The Campaign Registry: - **Use Org TCR Brand and Campaign(s)**: reuse the organization's registration. - **Use Org TCR Brand with Profile Dedicated Campaign**: keep the organization's brand but register a campaign specific to this profile. - **Use Profile Brand and Campaign**: register a fully separate brand and campaign. The trade-offs between these modes are covered in [TCR brand and campaign inheritance](/start/concepts/sender-profiles#tcr-brand-and-campaign). To create dedicated campaigns through the API afterwards, see the [10DLC Campaigns API](/start/advanced/10dlc-campaigns-api). ### Verify the profile is ready The final step runs setup in the background and shows **Setup Complete!** when it finishes, then returns you to the Profiles page. Confirm the new profile card shows the **COMPLETED** status, the `x-sender-id` value, and the phone number or WhatsApp account attached to each channel. ## Switch between profiles To work inside a specific profile (its templates, contacts, API keys, and settings), open the context switcher at the top of the sidebar and select the profile. Selecting **Organization** returns you to the organization-level view. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | | "Admin Access Required" on the Profiles page | Your user is not an ADMIN of the organization | Ask an organization administrator to create the profile or change your role | | Setup does not complete after several retries | Background provisioning failed | Try again shortly; if it keeps failing, contact support | | A channel shows "Not configured" on the profile card | No number or WhatsApp account attached for that channel | Edit the profile and complete the corresponding wizard step | ## Related - [Sender Profiles](/start/concepts/sender-profiles) explains the inheritance model behind each wizard step - [Integrating Sender Profiles into Your Application](/start/guides/integrating-sender-profiles) covers sending with profile API keys - [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api) automates this flow for tenant onboarding ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/error-handling.txt TITLE: Error Handling ================================================================================ URL: https://docs.sent.dm/llms/start/guides/error-handling.txt Handle errors gracefully with retries, circuit breakers, and fallback strategies # Error Handling This guide shows you how to build resilient messaging integrations with error classification, retries, fallbacks, and safe re-sends. ## Overview When sending messages, errors can occur at multiple levels: - **API errors** - Invalid requests, authentication failures - **Network errors** - Connection timeouts, DNS failures - **Provider errors** - Carrier issues, rate limiting - **Business errors** - Insufficient balance, template not approved This guide covers patterns for handling each type of error. ## Error Types ### API Errors (4xx) Client-side errors indicate a problem with the request. Fix the request instead of retrying: | Status | Example Code | Meaning | Action | |--------|--------------|---------|--------| | 400 | `VALIDATION_001` | Request validation failed | Fix the request body; check `error.details` | | 401 | `AUTH_002` | Invalid or missing API key | Verify the API key; don't retry | | 404 | `RESOURCE_001` | Resource not found | Verify IDs | | 409 | `CONFLICT_001` | Concurrent idempotent request in progress | Wait for the original request to complete | | 429 | `BUSINESS_002` | Rate limit exceeded | Implement backoff and respect `Retry-After` | These are the statuses the retry logic in this guide branches on. For every code with causes and remediation steps, see the [Error Catalog](/reference/api/error-catalog); for limit values, see [Rate Limits](/reference/api/rate-limits). ### Server Errors (5xx) Temporary server-side errors - safe to retry: | Status | Meaning | Action | |--------|---------|--------| | 500 | Internal server error | Retry with backoff | | 502 | Bad gateway | Retry with backoff | | 503 | Service unavailable | Retry with backoff | | 504 | Gateway timeout | Retry with backoff | ### Business Logic Errors Application-level errors requiring business decisions: | Error Code | Meaning | Action | |------------|---------|--------| | `BUSINESS_003` | Insufficient account balance | Alert billing, queue for later | | `BUSINESS_005` | Template not approved | Wait or use SMS fallback | | `BUSINESS_007` | Channel not available for this contact | Switch channel or use fallback | | `VALIDATION_002` | Invalid phone number format | Validate input, notify user | | `ERR_CONSENT_BLOCKED` | Recipient opted out: the message is finalized as `FILTERED` (asynchronous, surfaces on `message.filtered`) | Suppress the contact and skip further sends until renewed consent | `POST /v3/messages` accepts sends with `202` and applies business rules asynchronously, so opt-out, balance, and template-approval outcomes arrive as message statuses and webhooks rather than HTTP errors. The [Error Catalog](/reference/api/error-catalog) lists every business logic code with remediation steps. ## Basic Error Handling ### Try-Catch Pattern ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); async function sendMessage(phoneNumber: string, templateId: string, channels?: string[]) { try { const response = await client.messages.send({ to: [phoneNumber], template: { id: templateId }, // Omit channel for automatic selection; pass e.g. ['sms'] to pin the channel ...(channels ? { channel: channels } : {}) }); return { success: true, messageId: response.data.recipients[0].message_id }; } catch (error) { if (error instanceof SentDm.APIError) { return handleApiError(error); } // Network or other errors return { success: false, error: 'Network error', retryable: true }; } } function handleApiError(error: SentDm.APIError) { switch (error.status) { case 429: return { success: false, error: 'Rate limited', retryable: true, delay: 60000 }; case 401: return { success: false, error: 'Invalid API key', retryable: false }; case 400: return { success: false, error: error.message, retryable: false }; default: return { success: false, error: error.message, retryable: error.status >= 500 }; } } ``` ```python import sent_dm from sent_dm import SentDm client = SentDm() def send_message(phone_number: str, template_id: str): try: response = client.messages.send( to=[phone_number], template={"id": template_id} ) return {"success": True, "message_id": response.data.recipients[0].message_id} except sent_dm.RateLimitError as e: return {"success": False, "error": "Rate limited", "retryable": True, "delay": 60} except sent_dm.AuthenticationError as e: return {"success": False, "error": "Invalid API key", "retryable": False} except sent_dm.BadRequestError as e: return {"success": False, "error": str(e), "retryable": False} except sent_dm.APIStatusError as e: return {"success": False, "error": str(e), "retryable": e.status_code >= 500} except sent_dm.APIConnectionError as e: return {"success": False, "error": "Network error", "retryable": True} ``` ```go func sendMessage(phoneNumber, templateId string) (*SendResult, error) { client := sentdm.NewClient() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{phoneNumber}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String(templateId), }, }) if err != nil { if apiErr, ok := err.(*sentdm.APIError); ok { return nil, handleAPIError(apiErr) } // Network or other errors return &SendResult{Retryable: true, Error: err}, nil } return &SendResult{Success: true, MessageID: response.Data.Recipients[0].MessageID}, nil } func handleAPIError(err *sentdm.APIError) error { switch err.StatusCode { case 429: return fmt.Errorf("rate limited, retry after %d seconds", err.RetryAfter) case 401: return fmt.Errorf("invalid API key") case 400: return fmt.Errorf("bad request: %s", err.Message) default: if err.StatusCode >= 500 { return fmt.Errorf("server error: %s (retryable)", err.Message) } return fmt.Errorf("API error: %s", err.Message) } } ``` ```java public SendResult sendMessage(String phoneNumber, String templateId) { try { MessageSendParams params = MessageSendParams.builder() .addTo(phoneNumber) .template(MessageSendParams.Template.builder() .id(templateId) .build()) .build(); var response = client.messages().send(params); return new SendResult(true, response.data().recipients().get(0).messageId(), null); } catch (RateLimitException e) { return new SendResult(false, null, "Rate limited, retry after " + e.getRetryAfter()); } catch (AuthenticationException e) { return new SendResult(false, null, "Invalid API key"); } catch (BadRequestException e) { return new SendResult(false, null, e.getMessage()); } catch (APIException e) { boolean retryable = e.getStatusCode() >= 500; return new SendResult(false, null, e.getMessage() + (retryable ? " (retryable)" : "")); } } ``` ```csharp public async Task SendMessageAsync(string phoneNumber, string templateId) { try { var parameters = new MessageSendParams { To = new List { phoneNumber }, Template = new MessageSendParamsTemplate { Id = templateId } }; var response = await client.Messages.Send(parameters); return new SendResult(true, response.Data.Recipients[0].MessageId, null); } catch (RateLimitException ex) { return new SendResult(false, null, $"Rate limited, retry after {ex.RetryAfter}"); } catch (AuthenticationException ex) { return new SendResult(false, null, "Invalid API key"); } catch (BadRequestException ex) { return new SendResult(false, null, ex.Message); } catch (APIException ex) when (ex.StatusCode >= 500) { return new SendResult(false, null, $"{ex.Message} (retryable)"); } catch (APIException ex) { return new SendResult(false, null, ex.Message); } } ``` ```php function sendMessage($phoneNumber, $templateId) { try { $result = $this->client->messages->send( to: [$phoneNumber], template: ['id' => $templateId] ); return ['success' => true, 'message_id' => $result->data->recipients[0]->message_id]; } catch (RateLimitException $e) { return ['success' => false, 'error' => 'Rate limited', 'retryable' => true]; } catch (AuthenticationException $e) { return ['success' => false, 'error' => 'Invalid API key', 'retryable' => false]; } catch (BadRequestException $e) { return ['success' => false, 'error' => $e->getMessage(), 'retryable' => false]; } catch (APIException $e) { $retryable = $e->getCode() >= 500; return ['success' => false, 'error' => $e->getMessage(), 'retryable' => $retryable]; } } ``` ```ruby def send_message(phone_number, template_id) result = sent_dm.messages.send( to: [phone_number], template: { id: template_id } ) { success: true, message_id: result.data.recipients[0].message_id } rescue Sentdm::RateLimitError => e { success: false, error: 'Rate limited', retryable: true } rescue Sentdm::AuthenticationError => e { success: false, error: 'Invalid API key', retryable: false } rescue Sentdm::BadRequestError => e { success: false, error: e.message, retryable: false } rescue Sentdm::APIError => e retryable = e.code >= 500 { success: false, error: e.message, retryable: retryable } end ``` The remaining examples in this guide build on the TypeScript `sendMessage` helper from [Try-Catch Pattern](#try-catch-pattern). ## Retry Strategies ### Exponential Backoff Increase wait time between retries to avoid overwhelming the API: ```typescript async function sendWithRetry( phoneNumber: string, templateId: string, maxRetries: number = 3 ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const result = await sendMessage(phoneNumber, templateId); if (result.success || !result.retryable) { return result; } if (attempt < maxRetries) { // Exponential backoff: 1s, 2s, 4s const delay = Math.pow(2, attempt) * 1000; console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`); await sleep(delay); } } return { success: false, error: 'Max retries exceeded' }; } ``` ### Jitter Add randomness to prevent thundering herd: ```typescript function sleepWithJitter(baseDelay: number): Promise { const jitter = Math.random() * 1000; // 0-1000ms random return sleep(baseDelay + jitter); } // Usage const delay = Math.pow(2, attempt) * 1000; await sleepWithJitter(delay); ``` ### Circuit Breaker Pattern Retries handle brief errors; a circuit breaker handles sustained outages by failing fast instead of piling retries onto an API that is already struggling. The pattern itself is generic. Use a maintained circuit-breaker library for your platform rather than implementing the state machine yourself. Two rules matter when you wrap Sent calls: - Trip the breaker only on retryable failures: network errors and `5xx` responses. A `4xx` response means your request needs fixing, and a `429` needs backoff (see [Exponential Backoff](#exponential-backoff)), not an open circuit. - Wrap the `sendMessage` helper (or your equivalent) so queued sends fail fast while the circuit is open, then re-enter your retry flow when the probe request succeeds. ## Idempotency Prevent duplicate messages when retrying: ```typescript async function sendMessageIdempotent( phoneNumber: string, templateId: string, idempotencyKey: string ) { try { return await client.messages.send({ to: [phoneNumber], template: { id: templateId } }, { headers: { 'Idempotency-Key': idempotencyKey } }); } catch (error) { if (error.code === 'CONFLICT_001') { // Duplicate request - message already sent with this key console.log('Message already sent with this idempotency key'); return { success: true, duplicate: true }; } throw error; } } // Generate idempotency key from business context const idempotencyKey = `order_confirmation_${orderId}_${userId}`; await sendMessageIdempotent(phoneNumber, templateId, idempotencyKey); ``` ## Fallback Strategies ### Channel Fallback If you want Sent to handle fallback for you, omit the `channel` field: automatic selection is the only mode with cross-channel fallback, and it routes each message over another channel when the preferred one can't deliver. Refer to [Channel Selection Strategies](/start/guides/sending-messages#channel-selection-strategies) for how selection works. An explicit `channel` value pins the send and disables fallback. Because `POST /v3/messages` returns `202` before delivery, a pinned send that can't deliver fails asynchronously: the failure arrives as a [`message.failed` webhook event](/start/webhooks/event-types), not as an error response. To fall back manually, re-send on the other channel from your webhook handler: ```typescript // At send time: pin to WhatsApp and store the context keyed by message ID const result = await sendMessage(phoneNumber, templateId, ['whatsapp']); if (result.success) { await pendingSends.set(result.messageId, { phoneNumber, templateId }); } // In your webhook handler: re-send over SMS when the WhatsApp message fails async function handleFailedEvent(event) { if (event.event === 'message.failed' && event.payload.channel === 'whatsapp') { const context = await pendingSends.get(event.payload.message_id); if (context) { console.log('WhatsApp delivery failed, falling back to SMS'); await sendMessage(context.phoneNumber, context.templateId, ['sms']); await pendingSends.delete(event.payload.message_id); } } } ``` ### Queue for Later `POST /v3/messages` accepts sends with `202` even when your balance is too low. Each affected message then finalizes as `BLOCKED` and fires a [`message.blocked` webhook event](/start/webhooks/event-types) instead of returning a synchronous error, and blocked messages are not re-sent automatically after a top-up. Handle the event by queuing a re-send and alerting your billing owner, reusing the `pendingSends` store from the fallback example: ```typescript async function handleBlockedEvent(event) { if (event.event === 'message.blocked') { // Account-level gate (for example, insufficient balance); message not dispatched const context = await pendingSends.get(event.payload.message_id); if (context) { await retryQueue.add(context); await pendingSends.delete(event.payload.message_id); } await alertBillingTeam('Messages blocked - check account balance'); } } // After topping up, re-send queued messages as new requests for (const context of await retryQueue.drain()) { await sendMessage(context.phoneNumber, context.templateId); } ``` ## Monitoring and Alerting ### Error Metrics Track error rates to detect issues: ```typescript // Increment counters errorCounter.labels({ type: 'rate_limited' }).inc(); errorCounter.labels({ type: 'network' }).inc(); // Alert on high error rates if (errorRate > 0.1) { // 10% error rate await sendAlert('High message send error rate', { errorRate }); } ``` ### Structured Logging Log errors with context for debugging: ```typescript logger.error('Message send failed', { error: error.message, errorCode: error.code, phoneNumber: maskPhone(phoneNumber), templateId, attempt: attemptNumber, retryable: isRetryable(error) }); ``` ## Best Practices ### 1. Distinguish Retryable vs Non-Retryable ```typescript function isRetryable(error: APIError): boolean { // Never retry auth errors if (error.status === 401) { return false; } // Never retry payment / balance errors (legacy v2 send endpoints only; // v3 sends surface balance problems asynchronously as BLOCKED) if (error.status === 402) { return false; } // Don't retry validation errors if (error.status === 400 || error.status === 422) { return false; } // Retry server errors and rate limits return error.status >= 500 || error.status === 429; } ``` ### 2. Set Maximum Retry Limits Prevent infinite loops: ```typescript const MAX_RETRIES = 3; const MAX_DELAY = 30000; // 30 seconds const delay = Math.min(Math.pow(2, attempt) * 1000, MAX_DELAY); ``` ### 3. Fail Fast for User-Facing Errors Don't retry if user needs to fix something: ```typescript if (error.code === 'VALIDATION_002') { // Show error to user immediately return { success: false, userError: 'Please enter a valid phone number' }; } ``` ### 4. Use Dead Letter Queues For messages that ultimately fail: ```typescript async function processMessage(message: Message) { const result = await sendWithRetry(message); if (!result.success) { // Move to dead letter queue for manual review await deadLetterQueue.add({ originalMessage: message, error: result.error, attempts: result.attempts, failedAt: new Date() }); } } ``` ## Testing Error Handling To exercise each error path without sending real messages: - **Success path**: add `sandbox: true` to the request body. The API authenticates and validates the request, then returns `202` with `status: "QUEUED"` and generated message IDs. Nothing is sent or charged. See [Sandbox Mode](/reference/api/test-mode). - **Validation errors**: request validation runs even with `sandbox: true`, so a malformed request (for example, a phone number that isn't E.164) returns a real `400` your handler can be tested against. - **Provider and rate-limit errors**: sandbox mode does not simulate `429`, `5xx`, or delivery failures. Unit-test your retry and fallback branches by mocking the SDK client to throw those errors, as shown in [Testing & Debugging](/start/guides/testing-debugging). - **Webhook-driven paths**: trigger your `message.failed` and `message.blocked` handlers with the [webhook test endpoint](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint), which sends a test event to your registered URL. For failure injection beyond unit tests (dropped connections, dependency outages), apply standard chaos-engineering practice at your infrastructure layer. It requires no Sent-specific setup. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/handling-api-errors.txt TITLE: How to handle Sent API errors ================================================================================ URL: https://docs.sent.dm/llms/start/guides/handling-api-errors.txt Handle Sent API v3 error responses in client code: check the success flag, branch on error codes, capture request IDs, and test failures with sandbox mode. # How to handle Sent API errors This guide shows you how to handle Sent API v3 errors in your client code: detect failures, branch on error codes, capture request IDs for support, prevent avoidable errors, and test your handling without side effects. It assumes you can already authenticate and send requests to the API; if not, start with the [API reference overview](/reference/api). Every error uses the same JSON envelope with a machine-readable `code`. The [Error Handling reference](/reference/api/errors) documents the envelope and HTTP status codes; the [Error Catalog](/reference/api/error-catalog) enumerates every code. ## Check the success flag on every response The envelope's `success` boolean is the reliable failure signal. Check it before reading `data`: ```typescript const response = await fetch('/v3/messages', { ... }); const data: ApiResponse = await response.json(); if (!data.success) { // Handle error console.error(`Error ${data.error?.code}: ${data.error?.message}`); return; } // Process successful response console.log(data.data); ``` ```python import requests response = requests.post('/v3/messages', ...) data = response.json() if not data['success']: # Handle error print(f"Error {data['error']['code']}: {data['error']['message']}") return # Process successful response print(data['data']) ``` ```go resp, err := http.Post("/v3/messages", "application/json", body) if err != nil { log.Fatal(err) } defer resp.Body.Close() var data ApiResponse[Message] if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { log.Fatal(err) } if !data.Success { // Handle error log.Printf("Error %s: %s\n", data.Error.Code, data.Error.Message) return } // Process successful response log.Println(data.Data) ``` ## Branch on error codes, not messages Error messages can change; codes are stable. Branch on `error.code` and give each class of failure its own recovery path: ```typescript switch (data.error?.code) { case 'AUTH_001': case 'AUTH_002': // Redirect to login or refresh API key break; case 'RESOURCE_001': // Prompt user to create the resource break; case 'BUSINESS_002': // Implement retry with backoff const retryAfter = response.headers.get('Retry-After'); await sleep(parseInt(retryAfter || '60') * 1000); break; default: // Log unexpected error console.error('Unexpected error:', data.error); } ``` ```python error_code = data['error']['code'] if error_code in ('AUTH_001', 'AUTH_002'): # Redirect to login or refresh API key pass elif error_code == 'RESOURCE_001': # Prompt user to create the resource pass elif error_code == 'BUSINESS_002': # Implement retry with backoff retry_after = int(response.headers.get('Retry-After', 60)) time.sleep(retry_after) else: # Log unexpected error print('Unexpected error:', data['error']) ``` ```go switch data.Error.Code { case "AUTH_001", "AUTH_002": // Redirect to login or refresh API key case "RESOURCE_001": // Prompt user to create the resource case "BUSINESS_002": // Implement retry with backoff retryAfter := resp.Header.Get("Retry-After") seconds, _ := strconv.Atoi(retryAfter) if seconds == 0 { seconds = 60 } time.Sleep(time.Duration(seconds) * time.Second) default: // Log unexpected error log.Printf("Unexpected error: %+v\n", data.Error) } ``` If you retry on `BUSINESS_002` (rate limit) or 5xx errors, use exponential backoff. See [How to handle Sent API rate limits](/start/guides/handling-rate-limits) for a complete implementation. ## Log request IDs for support Every response includes `meta.request_id`. Log it with each failure, and include it when contacting [support@sent.dm](mailto:support@sent.dm). The ID lets support trace the exact request: ```typescript console.error(`Request ID: ${data.meta.request_id}`); // Provide this to support@sent.dm when reporting issues ``` ```python print(f"Request ID: {data['meta']['request_id']}") # Provide this to support@sent.dm when reporting issues ``` ```go log.Printf("Request ID: %s\n", data.Meta.RequestID) // Provide this to support@sent.dm when reporting issues ``` ## Make retries safe with idempotency keys If your error handling retries mutations (`POST`, `PUT`, `PATCH`), send an `Idempotency-Key` header so a retry after a timeout or 5xx error cannot perform the operation twice: ```http POST /v3/messages x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Idempotency-Key: send-msg-order-8412 Content-Type: application/json ``` Derive the key from a business identifier for the operation (an order ID, a client-generated request ID) and reuse the same key on every retry attempt. [How to retry Sent API requests safely](/start/guides/retrying-requests-safely) covers key derivation and retry loops in full. ## Validate inputs before sending Prevent the most common `VALIDATION_*` errors client-side. Phone numbers must be in E.164 format: ```typescript // Validate phone number format function isValidE164(phone: string): boolean { return /^\+[1-9]\d{1,14}$/.test(phone); } if (!isValidE164(phoneNumber)) { throw new Error('Phone number must be in E.164 format'); } ``` ```python import re # Validate phone number format def is_valid_e164(phone: str) -> bool: return bool(re.match(r'^\+[1-9]\d{1,14}$', phone)) if not is_valid_e164(phone_number): raise ValueError('Phone number must be in E.164 format') ``` ```go import "regexp" // Validate phone number format func isValidE164(phone string) bool { matched, _ := regexp.MatchString(`^\+[1-9]\d{1,14}$`, phone) return matched } if !isValidE164(phoneNumber) { log.Fatal("Phone number must be in E.164 format") } ``` When the server rejects input, read the `error.details` object. It carries field-level messages you can surface directly to users. ## Test your error handling with sandbox mode To exercise your error paths without sending real messages, add `sandbox: true` to any mutation request. Validation still runs, so invalid input returns real validation errors with no side effects: ```bash curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "sandbox": true, "to": ["invalid"], "template": {"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"} }' ``` This returns a `VALIDATION_*` error envelope without attempting to send a message. Refer to the [Sandbox Mode reference](/reference/api/test-mode) for the full contract. ## Verify your handling You have working error handling when: - A request with an invalid phone number surfaces the field-level message from `error.details` instead of a generic failure. - A `429` response triggers a delayed retry rather than an immediate one. - Failed requests appear in your logs with `error.code` and `meta.request_id`. ## Related - Refer to the [Error Handling reference](/reference/api/errors) for the envelope format, HTTP status codes, and common codes. - The [Error Catalog](/reference/api/error-catalog) lists every error code with causes and remediation. - [How to retry Sent API requests safely](/start/guides/retrying-requests-safely) - [How to handle Sent API rate limits](/start/guides/handling-rate-limits) ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/handling-rate-limits.txt TITLE: How to handle Sent API rate limits ================================================================================ URL: https://docs.sent.dm/llms/start/guides/handling-rate-limits.txt Keep your integration under Sent API v3 rate limits: back off on 429 responses, honor Retry-After, throttle requests client-side, and pace batch workloads. # How to handle Sent API rate limits This guide shows you how to keep an integration running smoothly under the Sent API v3 rate limits: recover from `429` responses, watch for limit pressure, and shape your own traffic so batch jobs and bursts stay under the account limit. It assumes you can already send authenticated requests; for the limit values and window semantics, see the [Rate Limits reference](/reference/api/rate-limits). Rate limits apply per customer account (200 requests per minute for standard endpoints), so every strategy below operates at the account level, not per API key. ## Back off and retry on 429 When a request returns `429 Too Many Requests`, wait the number of seconds in the `Retry-After` header before retrying. Fall back to exponential backoff if the header is absent: ```typescript async function makeRequestWithRetry( url: string, options: RequestInit, maxRetries = 3 ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch(url, options); if (response.status !== 429) { return response; } if (attempt === maxRetries) { throw new Error('Max retries exceeded'); } // Get retry delay from header or use exponential backoff const retryAfter = response.headers.get('Retry-After'); const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000; console.log(`Rate limited. Retrying after ${delay}ms...`); await sleep(delay); } throw new Error('Unreachable'); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python import time import requests from typing import Optional def make_request_with_retry( method: str, url: str, headers: dict, json_data: Optional[dict] = None, max_retries: int = 3 ) -> requests.Response: """Make request with exponential backoff on 429.""" for attempt in range(max_retries + 1): response = requests.request( method, url, headers=headers, json=json_data ) if response.status_code != 429: return response if attempt == max_retries: raise Exception('Max retries exceeded') # Get retry delay from header or use exponential backoff retry_after = response.headers.get('Retry-After') delay = int(retry_after) if retry_after else (2 ** attempt) print(f'Rate limited. Retrying after {delay}s...') time.sleep(delay) raise Exception('Unreachable') ``` ```go package main import ( "fmt" "math" "net/http" "strconv" "time" ) func makeRequestWithRetry( req *http.Request, maxRetries int, ) (*http.Response, error) { client := &http.Client{} for attempt := 0; attempt <= maxRetries; attempt++ { resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode != 429 { return resp, nil } if attempt == maxRetries { return nil, fmt.Errorf("max retries exceeded") } // Get retry delay from header or use exponential backoff retryAfter := resp.Header.Get("Retry-After") delay := math.Pow(2, float64(attempt)) if retryAfter != "" { if seconds, err := strconv.Atoi(retryAfter); err == nil { delay = float64(seconds) } } fmt.Printf("Rate limited. Retrying after %.0fs...\n", delay) time.Sleep(time.Duration(delay) * time.Second) } return nil, fmt.Errorf("unreachable") } ``` If the retried request is a mutation (`POST`, `PUT`, `PATCH`), send an `Idempotency-Key` so the retry can't duplicate the operation. See [How to retry Sent API requests safely](/start/guides/retrying-requests-safely). ## Monitor 429 responses as a pressure signal The API sends the `X-RateLimit-*` headers only on `429` responses, so there is no per-request quota readout to poll. Treat each `429` as the signal instead: count them in your metrics and alert when they occur, using the headers on the rejection to log when capacity returns: ```typescript async function makeRequest(url: string, options: RequestInit): Promise { const response = await fetch(url, options); if (response.status === 429) { const limit = response.headers.get('X-RateLimit-Limit'); const reset = response.headers.get('X-RateLimit-Reset'); console.warn( `Rate limited: limit ${limit}/min, window resets at ${new Date(parseInt(reset!) * 1000)}` ); // Send to your monitoring system // metrics.increment('api.rate_limited', { limit }); } return response; } ``` ```python import time import requests def make_request(url: str, headers: dict) -> requests.Response: """Make request and track 429 responses for monitoring.""" response = requests.get(url, headers=headers) if response.status_code == 429: limit = response.headers.get('X-RateLimit-Limit') reset = response.headers.get('X-RateLimit-Reset') reset_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(reset))) print(f'WARNING: rate limited: limit {limit}/min, window resets at {reset_time}') # Send to your monitoring system (Datadog, Prometheus, etc) # statsd.increment('api.rate_limited') return response ``` ```go package main import ( "fmt" "net/http" "strconv" "time" ) func makeRequest(req *http.Request) (*http.Response, error) { client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode == 429 { limit := resp.Header.Get("X-RateLimit-Limit") resetUnix, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64) resetTime := time.Unix(resetUnix, 0) fmt.Printf("WARNING: rate limited: limit %s/min, window resets at %s\n", limit, resetTime) // Send to your monitoring system // statsd.Increment("api.rate_limited") } return resp, nil } ``` A sustained 429 rate means your steady-state traffic exceeds the account limit. Throttle client-side (next section) or contact [support@sent.dm](mailto:support@sent.dm) about a higher limit. ## Throttle requests client-side To avoid hitting the limit at all, space out requests at the source. Pacing at 3 requests per second keeps you at 180 requests per minute, safely under the 200/minute standard limit: ```typescript class RateLimiter { private minInterval: number; private lastRequestTime: number = 0; constructor(requestsPerSecond: number) { this.minInterval = 1000 / requestsPerSecond; } async throttle(): Promise { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < this.minInterval) { const delay = this.minInterval - timeSinceLastRequest; await sleep(delay); } this.lastRequestTime = Date.now(); } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } // Use: limit to 3 requests per second (180/min, well under the 200 limit) const limiter = new RateLimiter(3); async function makeThrottledRequest(url: string, options: RequestInit): Promise { await limiter.throttle(); return fetch(url, options); } ``` ```python import time import requests from typing import Optional class RateLimiter: """Simple rate limiter that enforces a minimum interval between requests.""" def __init__(self, requests_per_second: float): self.min_interval = 1.0 / requests_per_second self.last_request_time: Optional[float] = None def throttle(self): """Wait if necessary to maintain rate limit.""" if self.last_request_time is None: self.last_request_time = time.time() return now = time.time() time_since_last = now - self.last_request_time if time_since_last < self.min_interval: delay = self.min_interval - time_since_last time.sleep(delay) self.last_request_time = time.time() # Use: limit to 3 requests per second (180/min, well under the 200 limit) limiter = RateLimiter(3) def make_throttled_request(url: str, headers: dict) -> requests.Response: limiter.throttle() return requests.get(url, headers=headers) ``` ```go package main import ( "net/http" "sync" "time" ) type RateLimiter struct { minInterval time.Duration lastRequestTime time.Time mu sync.Mutex } func NewRateLimiter(requestsPerSecond float64) *RateLimiter { return &RateLimiter{ minInterval: time.Duration(float64(time.Second) / requestsPerSecond), } } func (r *RateLimiter) Throttle() { r.mu.Lock() defer r.mu.Unlock() if r.lastRequestTime.IsZero() { r.lastRequestTime = time.Now() return } timeSinceLast := time.Since(r.lastRequestTime) if timeSinceLast < r.minInterval { time.Sleep(r.minInterval - timeSinceLast) } r.lastRequestTime = time.Now() } // Use: limit to 3 requests per second (180/min, well under the 200 limit) var limiter = NewRateLimiter(3) func makeThrottledRequest(req *http.Request) (*http.Response, error) { limiter.Throttle() client := &http.Client{} return client.Do(req) } ``` If several workers share one account, budget the limit across them: for example, four workers at 40 requests per minute each. Every API key on the account draws from the same pool. ## Cache responses and pace batch work Two ways to reduce the requests you send in the first place: cache reads you would otherwise repeat, and process bulk workloads in paced batches instead of all at once. ```typescript // ❌ Inefficient: unpaced individual requests (100 API calls at once) for (const contact of contacts) { await createContact(contact); } // ✅ Efficient: cache repeated reads, process writes in paced batches class CachedContactClient { private cache = new Map(); async getContact(id: string): Promise { // Return cached result if available if (this.cache.has(id)) { return this.cache.get(id)!; } const contact = await fetchContact(id); this.cache.set(id, contact); return contact; } async batchProcessContacts( contacts: Contact[], batchSize: number = 10 ): Promise { // Process in batches with rate limiting for (let i = 0; i < contacts.length; i += batchSize) { const batch = contacts.slice(i, i + batchSize); // Process batch concurrently await Promise.all( batch.map(contact => this.processContact(contact)) ); // Wait between batches if (i + batchSize < contacts.length) { await sleep(1000); } } } private async processContact(contact: Contact): Promise { // Implementation } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python from typing import Dict, List import time import requests # ❌ Inefficient: unpaced individual requests (100 API calls at once) # for contact in contacts: # create_contact(contact) # ✅ Efficient: cache repeated reads, process writes in paced batches class CachedContactClient: def __init__(self, api_key: str): self.api_key = api_key self.cache: Dict[str, dict] = {} def get_contact(self, contact_id: str) -> dict: """Get contact with caching.""" # Return cached result if available if contact_id in self.cache: return self.cache[contact_id] response = requests.get( f'https://api.sent.dm/v3/contacts/{contact_id}', headers={'x-api-key': self.api_key} ) contact = response.json()['data'] self.cache[contact_id] = contact return contact def batch_process_contacts( self, contacts: List[dict], batch_size: int = 10 ): """Process contacts in batches with rate limiting.""" for i in range(0, len(contacts), batch_size): batch = contacts[i:i + batch_size] # Process batch for contact in batch: self.process_contact(contact) # Wait between batches (except after last batch) if i + batch_size < len(contacts): time.sleep(1) def process_contact(self, contact: dict): """Process a single contact.""" # Implementation pass ``` ```go package main import ( "sync" "time" ) // ❌ Inefficient: unpaced individual requests (100 API calls at once) // for _, contact := range contacts { // createContact(contact) // } // ✅ Efficient: cache repeated reads, process writes in paced batches type CachedContactClient struct { apiKey string cache map[string]Contact mu sync.RWMutex } func NewCachedContactClient(apiKey string) *CachedContactClient { return &CachedContactClient{ apiKey: apiKey, cache: make(map[string]Contact), } } func (c *CachedContactClient) GetContact(id string) (Contact, error) { // Return cached result if available c.mu.RLock() if contact, ok := c.cache[id]; ok { c.mu.RUnlock() return contact, nil } c.mu.RUnlock() // Fetch and cache contact, err := fetchContact(id, c.apiKey) if err != nil { return Contact{}, err } c.mu.Lock() c.cache[id] = contact c.mu.Unlock() return contact, nil } func (c *CachedContactClient) BatchProcessContacts( contacts []Contact, batchSize int, ) error { if batchSize == 0 { batchSize = 10 } for i := 0; i < len(contacts); i += batchSize { end := i + batchSize if end > len(contacts) { end = len(contacts) } batch := contacts[i:end] // Process batch concurrently var wg sync.WaitGroup for _, contact := range batch { wg.Add(1) go func(c Contact) { defer wg.Done() processContact(c) }(contact) } wg.Wait() // Wait between batches if end < len(contacts) { time.Sleep(time.Second) } } return nil } func processContact(contact Contact) error { // Implementation return nil } type Contact struct { ID string PhoneNumber string } func fetchContact(id, apiKey string) (Contact, error) { // Implementation return Contact{}, nil } ``` A single `POST /v3/messages` request accepts multiple recipients, so sending to a list is one request, not one per recipient. For high-volume sending patterns, see the [Batch Operations guide](/start/guides/batch-operations). If you cache responses, remember that message and contact state changes server-side. Prefer [webhooks](/start/webhooks/getting-started) over polling for status updates, and expire cached reads accordingly. ## Verify your handling You have working rate-limit handling when: - A `429` response results in a delayed retry that succeeds, not a failed operation. - Your metrics show 429 counts, and a sustained increase triggers an alert. - Batch jobs complete without producing 429 bursts, because throttling and pacing keep steady-state traffic under 200 requests per minute. ## Related - Refer to the [Rate Limits reference](/reference/api/rate-limits) for limit values, window semantics, and the 429 response format. - [How to retry Sent API requests safely](/start/guides/retrying-requests-safely) - [How to handle Sent API errors](/start/guides/handling-api-errors) ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides.txt TITLE: Implementation Guides ================================================================================ URL: https://docs.sent.dm/llms/start/guides.txt Step-by-step guides for common messaging patterns with Sent: sending messages, tracking delivery status, managing contacts, templates, errors, and testing. # Implementation Guides Practical, step-by-step guides for implementing common messaging patterns with Sent. ## Guide Categories ### Core Messaging } /> } /> } /> } /> } /> ### Advanced Patterns } /> } /> } /> ## Quick Reference | Task | Guide | API Endpoint | |------|-------|--------------| | Send a message | [Sending Messages](/start/guides/sending-messages) | `POST /v3/messages` | | Track delivery | [Status Tracking](/start/guides/message-status-tracking) | Webhooks / `GET /v3/messages/{id}` | | Create contacts | [Managing Contacts](/start/guides/managing-contacts) | `POST /v3/contacts` | | Create templates | [Working with Templates](/start/guides/working-with-templates) | `POST /v3/templates` | | Handle errors | [Error Handling](/start/guides/error-handling) | - | **New to Sent?** Start with the [Quickstart](/start/quickstart) to send your first message, then return here for detailed implementation guidance. ## Getting Help - [Troubleshooting Guide](/start/reference-guides/troubleshooting) - Common issues and solutions - [Error Catalog](/reference/api/error-catalog) - Complete error code reference - [Support](/start/reference-guides/support) - Contact the support team --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/integrating-sender-profiles.txt TITLE: Integrating Sender Profiles into Your Application ================================================================================ URL: https://docs.sent.dm/llms/start/guides/integrating-sender-profiles.txt Route messages through Sender Profiles in a multi-tenant app: profile API keys, per-customer sends, and per-profile webhook tracking across eight languages. # Integrating Sender Profiles into Your Application This guide shows you how to route messaging traffic through Sender Profiles in your own app: send with a profile's API key, map customers to profiles in a multi-tenant platform, and attribute webhook events to the right profile. It assumes your organization already has at least one profile ([create one in the dashboard](/start/guides/creating-a-sender-profile) or [via the API](/start/advanced/sub-account-profiles-api)) and that you can already [send messages](/start/guides/sending-messages). For the inheritance model behind profiles, see [Sender Profiles](/start/concepts/sender-profiles). ## Send with a profile's API key Each Sender Profile has its own API credentials. The API key you authenticate with determines which profile's resources (templates, contacts, numbers, and compliance settings) the request uses, so sending as a profile is just sending with that profile's key: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $PROFILE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "template": {"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8"} }' ``` ```typescript import SentDm from '@sentdm/sentdm'; // Uses profile-specific API key const client = new SentDm({ apiKey: process.env.PROFILE_API_KEY }); await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' } }); ``` ```python from sent_dm import SentDm client = SentDm(api_key=os.environ['PROFILE_API_KEY']) client.messages.send( to=["+1234567890"], template={"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8"} ) ``` ```go package main import ( "context" "os" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) func main() { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("PROFILE_API_KEY")), ) client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), }, }) } ``` ```java SentDmClient client = SentDmOkHttpClient.builder() .apiKey(System.getenv("PROFILE_API_KEY")) .build(); client.messages().send(MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .build()) .build()); ``` ```csharp SentDmClient client = new(apiKey: Environment.GetEnvironmentVariable("PROFILE_API_KEY")); await client.Messages.Send(new MessageSendParams { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } }); ``` ```php use SentDM\Client; $client = new Client($_ENV['PROFILE_API_KEY']); $client->messages->send( to: ['+1234567890'], template: ['id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8'] ); ``` ```ruby require "sentdm" client = Sentdm::Client.new(api_key: ENV["PROFILE_API_KEY"]) client.messages.send( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } ) ``` If you prefer to keep a single credential instead of one key per profile, send with your organization API key and scope each request with the `x-profile-id` header (a profile UUID belonging to your organization). Only organization keys can use this header; profile-scoped keys are rejected with `403`. The [sub-account profiles guide](/start/advanced/sub-account-profiles-api#operate-on-the-child-profile) covers this pattern in detail. ## Map each customer to a profile For platforms serving multiple customers, store the profile-to-customer mapping in your own database and resolve the credentials per request: ```typescript async function sendOnBehalfOfCustomer(customerId: string, message: MessageRequest) { const profile = await db.senderProfiles.findByCustomer(customerId); const client = new SentDm({ apiKey: profile.apiKey }); return client.messages.send({ to: message.recipients, template: { id: message.templateId }, variables: message.variables }); } ``` ```python def send_on_behalf_of_customer(customer_id: str, message: dict): profile = db.sender_profiles.find_by_customer(customer_id) client = SentDm(api_key=profile.api_key) return client.messages.send( to=message["recipients"], template={"id": message["template_id"]}, variables=message["variables"] ) ``` ```go func sendOnBehalfOfCustomer(customerID string, msg MessageRequest) (*sentdm.MessageResponse, error) { profile, _ := db.SenderProfiles.FindByCustomer(customerID) client := sentdm.NewClient(option.WithAPIKey(profile.APIKey)) return client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: msg.Recipients, Template: sentdm.MessageSendParamsTemplate{ID: &msg.TemplateID}, }) } ``` ```java public MessageResponse sendOnBehalfOfCustomer(String customerId, MessageRequest message) { SenderProfile profile = db.senderProfiles().findByCustomer(customerId); SentDmClient client = SentDmOkHttpClient.builder() .apiKey(profile.getApiKey()) .build(); return client.messages().send(MessageSendParams.builder() .addTo(message.getRecipients().get(0)) .template(MessageSendParams.Template.builder() .id(message.getTemplateId()) .build()) .build()); } ``` ```csharp public async Task SendOnBehalfOfCustomer(string customerId, MessageRequest message) { var profile = await db.SenderProfiles.FindByCustomerAsync(customerId); var client = new SentDmClient(apiKey: profile.ApiKey); return await client.Messages.Send(new MessageSendParams { To = message.Recipients, Template = new MessageSendParamsTemplate { Id = message.TemplateId }, Variables = message.Variables }); } ``` ```php function sendOnBehalfOfCustomer(string $customerId, array $message): array { $profile = $this->db->senderProfiles->findByCustomer($customerId); $client = new Client($profile->apiKey); return $client->messages->send( to: $message['recipients'], template: ['id' => $message['template_id']], variables: $message['variables'] ?? [] ); } ``` ```ruby def send_on_behalf_of_customer(customer_id, message) profile = db.sender_profiles.find_by_customer(customer_id) client = Sentdm::Client.new(api_key: profile.api_key) client.messages.send( to: message[:recipients], template: { id: message[:template_id] }, variables: message[:variables] ) end ``` ## Track usage per profile in webhooks Webhook events do not carry your tenant identifiers, so attribute them yourself: store the `message_id` from each send response against the profile that sent it, then look the message up when the event arrives. See the [events reference](/start/webhooks/event-types) for the full envelope. ```typescript app.post('/webhooks/sent', async (req, res) => { res.sendStatus(200); const { field, payload } = req.body; if (field === 'message' && payload.message_status === 'SENT') { // Retrieve sender profile context from your database const message = await db.messages.findBySentId(payload.message_id); if (message?.senderProfileId) { await analytics.track('message_sent', { senderProfileId: message.senderProfileId, messageId: payload.message_id, channel: payload.channel, inboundNumber: payload.inbound_number, timestamp: new Date() }); } } }); ``` ```python @app.post("/webhooks/sent") async def webhook(request: Request): data = await request.json() p = data.get("payload", {}) if data.get("field") == "message" and p.get("message_status") == "SENT": # Retrieve sender profile context from your database message = db.messages.find_by_sent_id(p["message_id"]) if message and message.sender_profile_id: analytics.track("message_sent", { "sender_profile_id": message.sender_profile_id, "message_id": p["message_id"], "channel": p["channel"], "inbound_number": p["inbound_number"], "timestamp": datetime.now().isoformat() }) return {"status": "ok"} ``` ```go func webhookHandler(w http.ResponseWriter, r *http.Request) { var event struct { Field string `json:"field"` Payload struct { MessageID string `json:"message_id"` MessageStatus string `json:"message_status"` Channel string `json:"channel"` InboundNumber string `json:"inbound_number"` } `json:"payload"` } json.NewDecoder(r.Body).Decode(&event) w.WriteHeader(http.StatusOK) if event.Field == "message" && event.Payload.MessageStatus == "SENT" { // Retrieve sender profile context from your database message, _ := db.Messages.FindBySentID(event.Payload.MessageID) if message != nil && message.SenderProfileID != "" { analytics.Track("message_sent", map[string]interface{}{ "sender_profile_id": message.SenderProfileID, "message_id": event.Payload.MessageID, "channel": event.Payload.Channel, "inbound_number": event.Payload.InboundNumber, }) } } } ``` ```java @PostMapping("/webhooks/sent") public ResponseEntity webhook(@RequestBody WebhookEvent event) { if ("message".equals(event.getField()) && "SENT".equals(event.getPayload().getMessageStatus())) { // Retrieve sender profile context from your database MessageRecord message = db.messages().findBySentId(event.getPayload().getMessageId()); if (message != null && message.getSenderProfileId() != null) { analytics.track("message_sent", Map.of( "sender_profile_id", message.getSenderProfileId(), "message_id", event.getPayload().getMessageId(), "channel", event.getPayload().getChannel(), "inbound_number", event.getPayload().getInboundNumber() )); } } return ResponseEntity.ok().build(); } ``` ```csharp [ApiController] [Route("webhooks")] public class WebhookController : ControllerBase { [HttpPost("sent")] public async Task Webhook([FromBody] WebhookEvent evt) { if (evt.Field == "message" && evt.Payload.MessageStatus == "SENT") { // Retrieve sender profile context from your database var message = await db.Messages.FindBySentIdAsync(evt.Payload.MessageId); if (message?.SenderProfileId != null) { await analytics.TrackAsync("message_sent", new { sender_profile_id = message.SenderProfileId, message_id = evt.Payload.MessageId, channel = evt.Payload.Channel, inbound_number = evt.Payload.InboundNumber }); } } return Ok(); } } ``` ```php #[Post('/webhooks/sent')] public function webhook(Request $request): JsonResponse { $data = $request->getPayload()->all(); $p = $data['payload'] ?? []; if (($data['field'] ?? '') === 'message' && ($p['message_status'] ?? '') === 'SENT') { // Retrieve sender profile context from your database $message = $this->db->messages->findBySentId($p['message_id']); if ($message && $message->senderProfileId) { $this->analytics->track('message_sent', [ 'sender_profile_id' => $message->senderProfileId, 'message_id' => $p['message_id'], 'channel' => $p['channel'], 'inbound_number' => $p['inbound_number'], ]); } } return new JsonResponse(['status' => 'ok']); } ``` ```ruby post '/webhooks/sent' do request.body.rewind data = JSON.parse(request.body.read) p = data['payload'] || {} if data['field'] == 'message' && p['message_status'] == 'SENT' # Retrieve sender profile context from your database message = db.messages.find_by_sent_id(p['message_id']) if message && message.sender_profile_id analytics.track('message_sent', { sender_profile_id: message.sender_profile_id, message_id: p['message_id'], channel: p['channel'], inbound_number: p['inbound_number'] }) end end { status: 'ok' }.to_json end ``` ## Secure multi-tenant credentials - Store each profile's API key in a separate secrets-management entry so one leaked key exposes one tenant, not all of them. - [Verify webhook signatures](/start/webhooks/signature-verification) before trusting any event. - Apply per-profile rate limiting in your own app so one tenant cannot consume another tenant's throughput. ## Verify the integration Send a test message with one profile's key, then confirm the delivery events for that `message_id` resolve to the same profile in your database. If events arrive but the lookup finds no message, you are not persisting the `message_id` returned by the send call. ## Related - [Sender Profiles](/start/concepts/sender-profiles) explains inheritance, sharing, and isolation - [Creating a Sender Profile](/start/guides/creating-a-sender-profile) walks through the dashboard wizard - [Create and activate sub-account profiles via the API](/start/advanced/sub-account-profiles-api) automates tenant onboarding - [Multi-Tenant Architectures](/start/advanced/multi-tenant-architectures) compares shared and per-tenant setups - Refer to the [messages API reference](/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint) for the full list of send options ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/managing-contacts.txt TITLE: Managing Contacts ================================================================================ URL: https://docs.sent.dm/llms/start/guides/managing-contacts.txt Import your user base into Sent, honor opt-outs, and control channel routing per contact. Covers automatic contact creation, phone validation, and search. # Managing Contacts This guide shows you how to handle the contact tasks that come up in a real integration: importing your user base, honoring opt-outs, and controlling which channel reaches each contact. It assumes you have an [API key](/start/guides/api-keys) and can already [send messages](/start/guides/sending-messages). A contact is a validated phone number record that Sent creates and maintains for every number you message. It stores the normalized number, the channels that can reach it (`available_channels`), the channel Sent prefers (`default_channel`), and the opt-out flag (`opt_out`). Sending to a phone number creates or updates its contact automatically and routes the message over the best channel, so you manage contacts directly only for the tasks below. For the routing architecture behind this, see [Contacts concepts](/start/concepts/contacts). ## Import your user base Store each user's phone number in your own database. The phone number (not the Sent contact ID) is what you pass to the messages API; Sent keys the contact record to the number and keeps it updated: ```typescript // Store the E.164 phone number on your user record const user = await db.users.create({ email: 'jane@example.com', phoneNumber: '+1234567890' }); // Send directly to the phone number — Sent creates or // updates the contact automatically await client.messages.send({ to: [user.phoneNumber], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' } }); ``` If your users first hear from you in a campaign, this automatic creation is all you need: the first message to a new number creates its contact. For importing thousands of contacts at once, see the [Batch Operations](/start/guides/batch-operations) guide. ### Pre-create contacts To run validation and channel detection before your first campaign, create each contact explicitly: ```bash curl -X POST "https://api.sent.dm/v3/contacts" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+1234567890" }' ``` ```typescript const contact = await client.contacts.create({ phoneNumber: '+1234567890' }); console.log(`Contact created: ${contact.data.id}`); ``` ```python contact = client.contacts.create( phone_number="+1234567890" ) print(f"Contact created: {contact.data.id}") ``` ```go contact, err := client.Contacts.Create(context.Background(), sentdm.ContactCreateParams{ PhoneNumber: sentdm.String("+1234567890"), }) fmt.Println("Contact created:", contact.Data.ID) ``` ```java ContactCreateParams params = ContactCreateParams.builder() .phoneNumber("+1234567890") .build(); var contact = client.contacts().create(params); System.out.println("Contact created: " + contact.data().id()); ``` ```csharp ContactCreateParams parameters = new() { PhoneNumber = "+1234567890" }; var contact = await client.Contacts.Create(parameters); Console.WriteLine($"Contact created: {contact.Data.Id}"); ``` ```php $contact = $client->contacts->create( phoneNumber: '+1234567890' ); echo "Contact created: " . $contact->data->id . "\n"; ``` ```ruby contact = sent_dm.contacts.create( phone_number: "+1234567890" ) puts "Contact created: #{contact.data.id}" ``` If the number already has a contact, the API returns `409` with error code `RESOURCE_007`. Treat it as already imported and continue. ### Handle invalid numbers Numbers Sent cannot parse are rejected with `400` and error code `VALIDATION_002`. Surface the error to the user instead of retrying: ```typescript try { const contact = await client.contacts.create({ phoneNumber: 'invalid-number' }); } catch (error) { if (error.code === 'VALIDATION_002') { // Prompt user to correct their number showValidationError('Please enter a valid phone number'); } } ``` Always include the country code. Sent normalizes any parseable format to E.164: | Input Format | Normalized (E.164) | Display Format | |--------------|-------------------|----------------| | +1 234-567-8900 | +12345678900 | +1 234-567-8900 | | (234) 567-8900 | +12345678900 | +1 234-567-8900 | | 234-567-8900 | +12345678900 | +1 234-567-8900 | Numbers without country codes are rejected or may be misrouted. ### Verify the import Filter the contact list by phone number (URL-encode `+` as `%2B`) and confirm the record exists with its detected channels: ```bash curl "https://api.sent.dm/v3/contacts?page=1&page_size=100&phone=%2B1234567890" \ -H "x-api-key: $SENT_API_KEY" ``` ## Honor opt-outs The contact's `opt_out` flag is the consent record Sent checks on every send. It is per contact, not per channel: setting it suppresses SMS, WhatsApp, and RCS alike. Sent sets the flag automatically when a contact texts an opt-out keyword such as `STOP`. Set it yourself when a user revokes consent on any other surface (a preference center, a support ticket, an email unsubscribe): ```bash curl -X PATCH "https://api.sent.dm/v3/contacts/6ba7b810-9dad-11d1-80b4-00c04fd430c8" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"opt_out": true}' ``` Send `{"opt_out": false}` to restore consent, and only when you hold the contact's documented consent. The API does not check how the opt-out was created. You do not need a pre-send check of your own: a send to an opted-out contact is still accepted with `202`, then finalized as `FILTERED`, and a `message.filtered` webhook fires. For the full workflow (mirroring keyword opt-outs into your database and detecting consent-blocked sends), see [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent). ## Control channel routing By default, Sent picks each recipient's channel automatically. Two contact fields drive the decision: - `available_channels`: the channels that can reach the number, detected by Sent (for example `"sms,whatsapp"`). - `default_channel`: the channel Sent prefers for this contact. Sent adjusts it based on delivery results, and you can set it yourself. If a contact should receive messages on a specific channel by default, update `default_channel` (accepts `sms`, `whatsapp`, or `rcs`): ```bash curl -X PATCH "https://api.sent.dm/v3/contacts/6ba7b810-9dad-11d1-80b4-00c04fd430c8" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "default_channel": "whatsapp" }' ``` ```typescript const updated = await client.contacts.update('6ba7b810-9dad-11d1-80b4-00c04fd430c8', { defaultChannel: 'whatsapp' }); ``` ```python updated = client.contacts.update( "6ba7b810-9dad-11d1-80b4-00c04fd430c8", default_channel="whatsapp" ) ``` ```go updated, err := client.Contacts.Update(context.Background(), "6ba7b810-9dad-11d1-80b4-00c04fd430c8", sentdm.ContactUpdateParams{ DefaultChannel: sentdm.String("whatsapp"), }) ``` ```java ContactUpdateParams params = ContactUpdateParams.builder() .defaultChannel("whatsapp") .build(); var updated = client.contacts().update("6ba7b810-9dad-11d1-80b4-00c04fd430c8", params); ``` ```csharp ContactUpdateParams parameters = new() { DefaultChannel = "whatsapp" }; var updated = await client.Contacts.Update("6ba7b810-9dad-11d1-80b4-00c04fd430c8", parameters); ``` ```php $updated = $client->contacts->update( "6ba7b810-9dad-11d1-80b4-00c04fd430c8", defaultChannel: 'whatsapp' ); ``` ```ruby updated = sent_dm.contacts.update( "6ba7b810-9dad-11d1-80b4-00c04fd430c8", default_channel: "whatsapp" ) ``` Before switching a contact's default, confirm the target channel can reach them: ```typescript const contact = await client.contacts.get(contactId); // Check available channels before switching the default if (!contact.data.availableChannels.includes('whatsapp')) { console.log('WhatsApp not available for this contact — keep SMS'); } ``` The update response echoes the contact, so `default_channel` in the response confirms the change took effect. To pin a single send to a channel regardless of the contact's default, or to broadcast on several channels at once, set the `channel` field on the message itself. Refer to [Channel Selection Strategies](/start/guides/sending-messages#channel-selection-strategies) for how pinning disables cross-channel fallback. ## Find and inspect contacts Look up contacts when you reconcile your database with Sent or debug routing. `GET /v3/contacts` requires `page` and `page_size`, and supports `search`, `phone`, and `channel` filters: ```bash # List with pagination curl "https://api.sent.dm/v3/contacts?page=1&page_size=100" \ -H "x-api-key: $SENT_API_KEY" # Search contacts (URL-encode + as %2B) curl "https://api.sent.dm/v3/contacts?page=1&page_size=100&search=%2B1234" \ -H "x-api-key: $SENT_API_KEY" ``` ```typescript // List all contacts const contacts = await client.contacts.list({ page: 1, page_size: 100 }); // Search contacts const searchResults = await client.contacts.list({ search: '+1234' }); // Iterate through all pages for (const contact of contacts.data) { console.log(`${contact.phoneNumber} - ${contact.availableChannels}`); } ``` ```python # List all contacts contacts = client.contacts.list(page=1, page_size=100) # Search contacts search_results = client.contacts.list(search="+1234") # Iterate through results for contact in contacts.data: print(f"{contact.phone_number} - {contact.available_channels}") ``` ```go // List all contacts contacts, err := client.Contacts.List(context.Background(), sentdm.ContactListParams{ Page: sentdm.Int(1), PageSize: sentdm.Int(100), }) // Search contacts searchResults, err := client.Contacts.List(context.Background(), sentdm.ContactListParams{ Search: sentdm.String("+1234"), }) // Iterate through results for _, contact := range contacts.Data { fmt.Printf("%s - %s\n", contact.PhoneNumber, contact.AvailableChannels) } ``` ```java // List all contacts ContactListParams params = ContactListParams.builder() .page(1) .pageSize(100) .build(); var contacts = client.contacts().list(params); // Search contacts var searchResults = client.contacts().list(ContactListParams.builder() .search("+1234") .build()); // Iterate through results for (var contact : contacts.data()) { System.out.println(contact.phoneNumber() + " - " + contact.availableChannels()); } ``` ```csharp // List all contacts ContactListParams parameters = new() { Page = 1, PageSize = 100 }; var contacts = await client.Contacts.List(parameters); // Search contacts var searchResults = await client.Contacts.List(new ContactListParams { Search = "+1234" }); // Iterate through results foreach (var contact in contacts.Data) { Console.WriteLine($"{contact.PhoneNumber} - {contact.AvailableChannels}"); } ``` ```php // List all contacts $contacts = $client->contacts->list(page: 1, pageSize: 100); // Search contacts $searchResults = $client->contacts->list(search: '+1234'); // Iterate through results foreach ($contacts->data as $contact) { echo "{$contact->phone_number} - {$contact->available_channels}\n"; } ``` ```ruby # List all contacts contacts = sent_dm.contacts.list(page: 1, page_size: 100) # Search contacts search_results = sent_dm.contacts.list(search: "+1234") # Iterate through results contacts.data.each do |contact| puts "#{contact.phone_number} - #{contact.available_channels}" end ``` Each contact carries the fields the tasks in this guide depend on: ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "whatsapp", "opt_out": false, "is_inherited": false, "created_at": "2025-01-01T12:00:00Z", "updated_at": "2025-01-15T08:30:00Z" }, "error": null, "meta": { "request_id": "req_contact_001", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` To fetch one contact by ID, call `GET /v3/contacts/{id}`. Refer to the [get contact reference](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint) for the response contract. ## Delete a contact If a user asks you to remove their data, delete the contact: ```bash curl -X DELETE "https://api.sent.dm/v3/contacts/6ba7b810-9dad-11d1-80b4-00c04fd430c8" \ -H "x-api-key: $SENT_API_KEY" ``` Deleting a contact is permanent. Messages already sent to this contact retain their delivery records, but you can no longer reference the contact in future API calls. ## Contacts API reference This guide covers only what these tasks need. For the full contract of every endpoint (parameters, response fields, and error codes), refer to the Contacts API reference: - [Create a contact](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint) - [List contacts](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint) - [Get a contact by ID](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint) - [Update a contact](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint) - [Delete a contact](/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint) ## Next steps - [Sending Messages](/start/guides/sending-messages): template sends, free-form text, and channel selection - [Batch Operations](/start/guides/batch-operations): import contacts in bulk - [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent): mirror opt-outs into your database and detect consent-blocked sends - [Contacts concepts](/start/concepts/contacts): the architecture behind channel detection and routing ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/message-best-practices.txt TITLE: Message Best Practices ================================================================================ URL: https://docs.sent.dm/llms/start/guides/message-best-practices.txt Harden your Sent send path for production: store per-recipient message IDs, make critical sends idempotent, apply RCS send rules, and estimate bulk costs. # Message Best Practices This guide shows you how to harden your send path for production: store per-recipient message IDs, make critical sends idempotent, apply RCS-specific send rules, and estimate spend before a bulk send. It assumes you already [send messages](/start/guides/sending-messages) through an SDK or the REST API. Adjacent production concerns each have a dedicated guide: [Error Handling](/start/guides/error-handling), [Message Status Tracking](/start/guides/message-status-tracking), and [Testing & Debugging](/start/guides/testing-debugging). The sections below link to them where they fit into the send path. ## Store Message IDs Every send returns one recipient entry per (recipient, channel) pair. Store each `message_id`, because webhook status events and `GET /v3/messages/{id}` lookups key on it: ```typescript // Store in your database - one entry per (recipient, channel) pair const recipients = response.data.recipients; for (const r of recipients) { await db.messages.create({ sentMessageId: r.message_id, recipient: r.to, channel: r.channel, templateId: response.data.template_id, templateName: response.data.template_name, status: response.data.status }); } ``` ```python # Store in your database - one entry per (recipient, channel) pair for r in response.data.recipients: db.messages.create({ "sent_message_id": r.message_id, "recipient": r.to, "channel": r.channel, "template_id": response.data.template_id, "template_name": response.data.template_name, "status": response.data.status }) ``` ```go // Store in your database - one entry per (recipient, channel) pair for _, r := range response.Data.Recipients { db.Messages.Create(MessageRecord{ SentMessageID: r.MessageID, Recipient: r.To, Channel: r.Channel, TemplateID: response.Data.TemplateID, TemplateName: response.Data.TemplateName, Status: response.Data.Status, }) } ``` ```java // Store in your database - one entry per (recipient, channel) pair for (var r : response.data().recipients()) { db.messages().create(MessageRecord.builder() .sentMessageId(r.messageId()) .recipient(r.to()) .channel(r.channel()) .templateId(response.data().templateId()) .templateName(response.data().templateName()) .status(response.data().status()) .build()); } ``` ```csharp // Store in your database - one entry per (recipient, channel) pair foreach (var r in response.Data.Recipients) { await db.Messages.CreateAsync(new MessageRecord { SentMessageId = r.MessageId, Recipient = r.To, Channel = r.Channel, TemplateId = response.Data.TemplateId, TemplateName = response.Data.TemplateName, Status = response.Data.Status }); } ``` ```php // Store in your database - one entry per (recipient, channel) pair foreach ($result->data->recipients as $r) { $db->messages->create([ 'sent_message_id' => $r->message_id, 'recipient' => $r->to, 'channel' => $r->channel, 'template_id' => $result->data->template_id, 'template_name' => $result->data->template_name, 'status' => $result->data->status ]); } ``` ```ruby # Store in your database - one entry per (recipient, channel) pair result.data.recipients.each do |r| db.messages.create( sent_message_id: r.message_id, recipient: r.to, channel: r.channel, template_id: result.data.template_id, template_name: result.data.template_name, status: result.data.status ) end ``` ## Make Critical Sends Idempotent For critical messages (OTP, payments), use idempotency keys via the `Idempotency-Key` header so a retry after a network failure can't double-send: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: otp_user_123_20250115" \ -d '{ "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } }' ``` ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' } }, { idempotencyKey: 'otp_user_123_20250115' }); ``` ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, idempotency_key="otp_user_123_20250115" ) ``` ```go response, err := client.Messages.Send( context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), }, }, option.WithIdempotencyKey("otp_user_123_20250115"), ) ``` ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .build()) .build(); var response = client.messages().send(params, RequestOptions.builder() .idempotencyKey("otp_user_123_20250115") .build()); ``` ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } }; var response = await client.Messages.Send( parameters, new RequestOptions { IdempotencyKey = "otp_user_123_20250115" } ); ``` ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8' ], idempotencyKey: 'otp_user_123_20250115' ); ``` ```ruby result = sent_dm.messages.send( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, idempotency_key: "otp_user_123_20250115" ) ``` Keys are cached for 24 hours. Reusing a key returns the original response without sending a duplicate message. ## Handle Failures Classify errors before reacting: back off on `429` rate limits, fix rather than retry `4xx` validation failures, and retry `5xx` with exponential backoff. The [Error Handling guide](/start/guides/error-handling) owns these patterns, including retry loops, circuit breakers, and channel fallbacks. ## Track Status with Webhooks Don't poll for status. Configure [webhooks](/start/webhooks/getting-started) and update the message records you stored in [Store Message IDs](#store-message-ids) as status events arrive. Refer to [Message Status Tracking](/start/guides/message-status-tracking) for handler examples, payload shapes, and the full status lifecycle. ## RCS Best Practices When sending on RCS, a few channel-specific considerations improve delivery and engagement: - **Use suggestion chips for interaction**: Template buttons render as tappable suggestion chips below the message: quick reply, open URL, and dial number actions. Sent maps up to four template buttons to chips and truncates chip labels to 25 characters, so keep button text short. - **Don't add manual opt-out text**: Every RCS message automatically includes a STOP suggestion chip. Adding your own opt-out footer is redundant and clutters the message. - **Handle inbound opt-out keywords**: Taps on the built-in STOP chip arrive as `message.received` webhook events, and Sent's consent engine flips the contact's `opt_out` flag automatically. Refer to [RCS Suggestion Chips](/start/guides/two-way-conversations#rcs-suggestion-chips) for how chip taps and keywords are processed, and mirror the suppression in your own datastore. - **Always include SMS fallback**: Use `"channel": ["rcs", "sms"]` rather than `["rcs"]` alone. This ensures delivery to recipients on devices or carriers without RCS support. Rich cards and carousel cards are part of the RCS standard but are not yet available through Sent. RCS messages currently render as text plus suggestion chips. ## Estimate Costs Before a Bulk Send Rates vary by channel, by destination country, and on SMS, by segment count: each segment of a multi-segment body bills as a separate message (refer to [SMS Length & Cost](/start/guides/sms-length-and-cost)). The API doesn't expose a rate card, so build an estimate from two numbers it does give you. **Count messages with a sandbox request.** Add `sandbox: true` to the bulk request. The response echoes one recipient entry per (recipient, channel) pair without sending anything, so the length of `recipients` is your exact message count. List channels explicitly; if you omit `channel` (auto-detect), entries return `channel: null` and you can't attribute the count to a channel's rate. [Testing & Debugging](/start/guides/testing-debugging#sandbox-mode) documents sandbox mode itself. **Read real prices from a pilot send.** After a real send, `GET /v3/messages/{id}` returns `price` (channel cost) and `active_contact_price` (markup applied on top) for that message. Send the template to a small pilot group in the target country, average the sum of both fields, and multiply by the message count: ```typescript // 1. Count messages without sending - sandbox echoes the real fan-out const test = await client.messages.send({ to: recipients, // the full bulk list channel: ['sms'], // explicit channel keeps the count attributable template: { id: templateId }, sandbox: true }); const messageCount = test.data.recipients.length; // 2. Average actual per-message price from an earlier pilot send const pilot = await Promise.all( pilotMessageIds.map((id) => client.messages.retrieveStatus(id)) ); const perMessage = pilot.reduce( (sum, m) => sum + (m.data.price ?? 0) + (m.data.active_contact_price ?? 0), 0 ) / pilot.length; console.log( `~${messageCount} messages x $${perMessage.toFixed(4)} each = ~$${(messageCount * perMessage).toFixed(2)}` ); ``` Sandbox `message_id` values are simulated and reference no stored message, so price lookups only work for messages from real sends. ## Related Guides ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/message-responses.txt TITLE: Message Response Handling ================================================================================ URL: https://docs.sent.dm/llms/start/guides/message-responses.txt Parse Sent API v3 message-send responses: the success/data/error envelope, per-recipient message IDs, validation and auth error shapes, and async failures. # Message Response Handling This page is a lookup for the responses `POST /v3/messages` returns: the `success`/`data`/`error` envelope, per-recipient message IDs, and the error payloads for validation, authentication, and rate-limit failures. For production retry patterns, refer to [Error Handling](/start/guides/error-handling). For inbound messages and RCS suggestion-chip taps, refer to [Two-Way Conversations](/start/guides/two-way-conversations). ## Response Structure All API responses follow a consistent structure: ```json { "success": boolean, "data": object | null, "error": object | null, "meta": { "request_id": string, "timestamp": string, "version": "v3" } } ``` | Field | Type | Description | |-------|------|-------------| | `success` | boolean | `true` for 2xx responses, `false` for errors | | `data` | object \| null | Response payload (null on error) | | `error` | object \| null | Error details (null on success) | | `meta` | object | Request metadata including `request_id` for support | ## Success Response (202 Accepted) When a message is successfully accepted: ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: 'tmpl_123' } }); // Access response data console.log(response.data.status); // "QUEUED" console.log(response.data.template_id); // Template UUID console.log(response.data.template_name); // Access recipient info const recipient = response.data.recipients[0]; console.log(recipient.message_id); // Message UUID for tracking console.log(recipient.to); // Phone number console.log(recipient.channel); // "sms", "whatsapp", or "rcs" // Access metadata console.log(response.meta.request_id); // For support inquiries console.log(response.meta.timestamp); // Response timestamp ``` ```python response = client.messages.send( to=["+1234567890"], template={"id": "tmpl_123"} ) # Access response data print(response.data.status) # "QUEUED" print(response.data.template_id) # Template UUID print(response.data.template_name) # Access recipient info recipient = response.data.recipients[0] print(recipient.message_id) # Message UUID for tracking print(recipient.to) # Phone number print(recipient.channel) # "sms", "whatsapp", or "rcs" # Access metadata print(response.meta.request_id) # For support inquiries print(response.meta.timestamp) # Response timestamp ``` ```go response, err := client.Messages.Send(context.Background(), params) if err != nil { log.Fatal(err) } // Access response data fmt.Println(response.Data.Status) // "QUEUED" fmt.Println(response.Data.TemplateID) // Template UUID fmt.Println(response.Data.TemplateName) // Access recipient info recipient := response.Data.Recipients[0] fmt.Println(recipient.MessageID) // Message UUID for tracking fmt.Println(recipient.To) // Phone number fmt.Println(recipient.Channel) // "sms", "whatsapp", or "rcs" // Access metadata fmt.Println(response.Meta.RequestID) // For support inquiries fmt.Println(response.Meta.Timestamp) // Response timestamp ``` ```java var response = client.messages().send(params); // Access response data System.out.println(response.data().status()); // "QUEUED" System.out.println(response.data().templateId()); // Template UUID System.out.println(response.data().templateName()); // Access recipient info var recipient = response.data().recipients().get(0); System.out.println(recipient.messageId()); // Message UUID for tracking System.out.println(recipient.to()); // Phone number System.out.println(recipient.channel()); // "sms", "whatsapp", or "rcs" // Access metadata System.out.println(response.meta().requestId()); // For support inquiries System.out.println(response.meta().timestamp()); // Response timestamp ``` ```csharp var response = await client.Messages.Send(parameters); // Access response data Console.WriteLine(response.Data.Status); // "QUEUED" Console.WriteLine(response.Data.TemplateId); // Template UUID Console.WriteLine(response.Data.TemplateName); // Access recipient info var recipient = response.Data.Recipients[0]; Console.WriteLine(recipient.MessageId); // Message UUID for tracking Console.WriteLine(recipient.To); // Phone number Console.WriteLine(recipient.Channel); // "sms", "whatsapp", or "rcs" // Access metadata Console.WriteLine(response.Meta.RequestId); // For support inquiries Console.WriteLine(response.Meta.Timestamp); // Response timestamp ``` ```php $result = $client->messages->send( to: ['+1234567890'], template: ['id' => 'tmpl_123'] ); // Access response data echo $result->data->status; // "QUEUED" echo $result->data->template_id; // Template UUID echo $result->data->template_name; // Access recipient info $recipient = $result->data->recipients[0]; echo $recipient->message_id; // Message UUID for tracking echo $recipient->to; // Phone number echo $recipient->channel; // "sms", "whatsapp", or "rcs" // Access metadata echo $result->meta->request_id; // For support inquiries echo $result->meta->timestamp; // Response timestamp ``` ```ruby result = sent_dm.messages.send( to: ["+1234567890"], template: { id: "tmpl_123" } ) # Access response data puts result.data.status # "QUEUED" puts result.data.template_id # Template UUID puts result.data.template_name # Access recipient info recipient = result.data.recipients[0] puts recipient.message_id # Message UUID for tracking puts recipient.to # Phone number puts recipient.channel # "sms", "whatsapp", or "rcs" # Access metadata puts result.meta.request_id # For support inquiries puts result.meta.timestamp # Response timestamp ``` ### Multi-Channel Response When sending to multiple channels, you get one recipient entry per (recipient, channel) pair: ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "msg-uuid-1", "to": "+14155551234", "channel": "whatsapp" }, { "message_id": "msg-uuid-2", "to": "+14155551234", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` ## Error Responses ### 400 Bad Request (Validation Error) ```json { "success": false, "status": 400, "error": { "code": "VALIDATION_004", "message": "Request validation failed", "details": { "to": ["'to' must contain at least one recipient"], "template": ["'template' is required"] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` ```typescript import { ValidationError } from '@sentdm/sentdm'; try { const response = await client.messages.send({ to: [], // Empty array - will fail validation template: { id: 'tmpl_123' } }); } catch (error) { if (error instanceof ValidationError) { console.log(error.code); // "VALIDATION_004" console.log(error.message); // "Request validation failed" console.log(error.details); // { to: [...], template: [...] } console.log(error.docUrl); // Documentation URL } } ``` ```python from sent_dm.errors import ValidationError try: response = client.messages.send( to=[], # Empty list - will fail validation template={"id": "tmpl_123"} ) except ValidationError as e: print(e.code) # "VALIDATION_004" print(e.message) # "Request validation failed" print(e.details) # {"to": [...], "template": [...]} print(e.doc_url) # Documentation URL ``` ```go response, err := client.Messages.Send(context.Background(), params) if err != nil { if validationErr, ok := err.(*sentdm.ValidationError); ok { fmt.Println(validationErr.Code) // "VALIDATION_004" fmt.Println(validationErr.Message) // "Request validation failed" fmt.Println(validationErr.Details) // Field-specific errors fmt.Println(validationErr.DocURL) // Documentation URL } } ``` ```java try { var response = client.messages().send(params); } catch (ValidationException e) { System.out.println(e.getCode()); // "VALIDATION_004" System.out.println(e.getMessage()); // "Request validation failed" System.out.println(e.getDetails()); // Field-specific errors System.out.println(e.getDocUrl()); // Documentation URL } ``` ```csharp try { var response = await client.Messages.Send(parameters); } catch (ValidationException ex) { Console.WriteLine(ex.Code); // "VALIDATION_004" Console.WriteLine(ex.Message); // "Request validation failed" Console.WriteLine(ex.Details); // Field-specific errors Console.WriteLine(ex.DocUrl); // Documentation URL } ``` ```php use SentDM\Exceptions\ValidationException; try { $result = $client->messages->send(to: [], template: ['id' => 'tmpl_123']); } catch (ValidationException $e) { echo $e->getCode(); // "VALIDATION_004" echo $e->getMessage(); // "Request validation failed" print_r($e->getDetails()); // Field-specific errors echo $e->getDocUrl(); // Documentation URL } ``` ```ruby begin result = sent_dm.messages.send(to: [], template: { id: "tmpl_123" }) rescue Sentdm::ValidationError => e puts e.code # "VALIDATION_004" puts e.message # "Request validation failed" puts e.details # Field-specific errors puts e.doc_url # Documentation URL end ``` ### 401 Unauthorized Invalid or missing API key: ```json { "success": false, "status": 401, "error": { "code": "AUTH_001", "message": "Invalid API key", "doc_url": "https://docs.sent.dm/reference/api/authentication" } } ``` ### 404 Not Found Template not found: ```json { "success": false, "status": 404, "error": { "code": "RESOURCE_002", "message": "Template not found", "doc_url": "https://docs.sent.dm/reference/api/error-catalog" } } ``` ### 429 Rate Limit Too many requests: ```json { "success": false, "status": 429, "error": { "code": "BUSINESS_002", "message": "Rate limit exceeded. Retry after 60 seconds", "details": { "retry_after": 60, "limit": 200, "window": "60s" }, "doc_url": "https://docs.sent.dm/reference/api/rate-limits" } } ``` ### Insufficient Balance (Asynchronous) `POST /v3/messages` does not return a synchronous payment error. Sends are accepted with `202` even when your balance is too low; each affected message is then finalized as `BLOCKED` and fires a [`message.blocked` webhook event](/start/webhooks/event-types). Refer to [Queue for Later](/start/guides/error-handling#queue-for-later) in the Error Handling guide for the recovery pattern, and to [`BUSINESS_003`](/reference/api/error-catalog#business_003-insufficient-account-balance) in the Error Catalog for the legacy v2 behavior. ## Error Code Reference The preceding examples show the response shapes your handler parses. For the complete enumeration of error codes with causes and remediation steps, refer to the [Error Catalog](/reference/api/error-catalog). ## Handling Errors in Production The [Error Handling guide](/start/guides/error-handling) owns production error handling: classifying retryable failures, exponential backoff with jitter, circuit breakers, and channel fallbacks. For safe re-sends with idempotency keys, refer to [How to retry Sent API requests safely](/start/guides/retrying-requests-safely). ## Related Guides --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/message-status-tracking.txt TITLE: Message Status Tracking ================================================================================ URL: https://docs.sent.dm/llms/start/guides/message-status-tracking.txt Track Sent message delivery status in real time using webhook status events, API polling, and the dashboard, and handle failed messages and inbound replies. # Message Status Tracking Track the delivery status of your messages in real-time using webhooks, the API, or the Sent Dashboard. ## Overview After sending a message, it progresses through several statuses: ## Tracking Methods ### Method 1: Webhooks (Recommended) Receive real-time status updates via HTTP callbacks to your server. **Advantages:** - Real-time updates (within seconds) - No polling required - Scalable for high volume **Setup:** 1. Create a webhook endpoint in your app 2. Configure the webhook URL in your [Sent Dashboard](https://app.sent.dm/dashboard/webhooks) 3. Handle incoming events ```typescript import express from 'express'; const app = express(); app.use(express.json()); app.post('/webhooks/sent', async (req, res) => { res.sendStatus(200); // Acknowledge quickly const { field, event, payload } = req.body; if (field === 'message') { if (event === 'message.received') { // Inbound message — see the Two-Way Conversations guide await handleInboundMessage(payload); return; } // Outbound message status update const { message_id, message_status, channel } = payload; // Update your database await db.messages.update(message_id, { status: message_status, channel: channel, updatedAt: new Date() }); // Trigger business logic if (message_status === 'DELIVERED') { await handleDeliveryConfirmation(message_id); } else if (message_status === 'FAILED') { await handleDeliveryFailure(message_id); } else if (message_status === 'FILTERED' || message_status === 'BLOCKED') { // Terminal, but not a delivery failure: no carrier attempt was made await handleSuppressed(message_id, message_status); } else if (message_status === 'SCHEDULED') { // Held until the recipient's quiet hours end, then released automatically await markHeld(message_id); } } }); ``` ```python from flask import Flask, request app = Flask(__name__) @app.route('/webhooks/sent', methods=['POST']) def handle_webhook(): data = request.json field = data['field'] event = data.get('event') if field == 'message': p = data['payload'] if event == 'message.received': # Inbound message — see the Two-Way Conversations guide handle_inbound_message(p) return '', 200 # Outbound message status update message_id = p['message_id'] message_status = p['message_status'] channel = p['channel'] # Update database db.messages.update(message_id, status=message_status, channel=channel) # Business logic if message_status == 'DELIVERED': handle_delivery_confirmation(message_id) elif message_status == 'FAILED': handle_delivery_failure(message_id) elif message_status in ('FILTERED', 'BLOCKED'): # Terminal, but not a delivery failure handle_suppressed(message_id, message_status) elif message_status == 'SCHEDULED': # Held until quiet hours end, then released automatically mark_held(message_id) return '', 200 ``` ```go func webhookHandler(w http.ResponseWriter, r *http.Request) { var event WebhookEvent json.NewDecoder(r.Body).Decode(&event) w.WriteHeader(http.StatusOK) // Acknowledge quickly if event.Field == "message" { if event.Event == "message.received" { // Inbound message — see the Two-Way Conversations guide handleInboundMessage(event.Payload) return } // Outbound message status update messageID := event.Payload.MessageID messageStatus := event.Payload.MessageStatus channel := event.Payload.Channel // Update database db.Messages.Update(messageID, messageStatus, channel) // Business logic if messageStatus == "DELIVERED" { handleDeliveryConfirmation(messageID) } else if messageStatus == "FAILED" { handleDeliveryFailure(messageID) } else if messageStatus == "FILTERED" || messageStatus == "BLOCKED" { // Terminal, but not a delivery failure handleSuppressed(messageID, messageStatus) } else if messageStatus == "SCHEDULED" { // Held until quiet hours end, then released automatically markHeld(messageID) } } } ``` **Webhook Event Structure:** ```json { "field": "message", "event": "message.delivered", "timestamp": "2025-01-15T08:30:15Z", "payload": { "updated_at": "2025-01-15T08:30:15Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "message_status": "DELIVERED", "channel": "sms" } } ``` See the [Webhooks Guide](/start/webhooks/getting-started) for complete setup instructions. ### Method 2: API Polling Query message status via the API. Useful for one-off checks or debugging. ```bash curl "https://api.sent.dm/v3/messages/8ba7b830-9dad-11d1-80b4-00c04fd430c8" \ -H "x-api-key: $SENT_API_KEY" ``` ```typescript const message = await client.messages.retrieveStatus('8ba7b830-9dad-11d1-80b4-00c04fd430c8'); console.log(`Status: ${message.data.status}`); console.log(`Events:`, message.data.events); ``` ```python message = client.messages.retrieve_status("8ba7b830-9dad-11d1-80b4-00c04fd430c8") print(f"Status: {message.data.status}") print(f"Events: {message.data.events}") ``` ```go message, err := client.Messages.RetrieveStatus(context.Background(), "8ba7b830-9dad-11d1-80b4-00c04fd430c8") fmt.Printf("Status: %s\n", message.Data.Status) fmt.Printf("Events: %v\n", message.Data.Events) ``` ```java var message = client.messages().retrieveStatus("8ba7b830-9dad-11d1-80b4-00c04fd430c8"); System.out.println("Status: " + message.data().status()); System.out.println("Events: " + message.data().events()); ``` ```csharp var message = await client.Messages.RetrieveStatus("8ba7b830-9dad-11d1-80b4-00c04fd430c8"); Console.WriteLine($"Status: {message.Data.Status}"); Console.WriteLine($"Events: {message.Data.Events}"); ``` ```php $message = $client->messages->retrieveStatus("8ba7b830-9dad-11d1-80b4-00c04fd430c8"); echo "Status: {$message->data->status}\n"; echo "Events: " . json_encode($message->data->events) . "\n"; ``` ```ruby message = sent_dm.messages.retrieve_status("8ba7b830-9dad-11d1-80b4-00c04fd430c8") puts "Status: #{message.data.status}" puts "Events: #{message.data.events}" ``` **Response:** ```json { "success": true, "data": { "id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "customer_id": "5ba7b800-9dad-11d1-80b4-00c04fd430c8", "contact_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone": "+1234567890", "phone_international": "+1 234-567-890", "region_code": "US", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "template_category": "UTILITY", "channel": "sms", "message_body": { "header": null, "content": "Your order #12345 has been shipped!", "footer": null, "buttons": null }, "status": "DELIVERED", "direction": "OUTBOUND", "created_at": "2025-01-15T08:30:00Z", "price": 0.0055, "active_contact_price": 0.001, "events": [ { "status": "QUEUED", "timestamp": "2025-01-15T08:30:00Z", "description": "Message queued for sending" }, { "status": "SENT", "timestamp": "2025-01-15T08:30:01Z", "description": "Message sent via SMS" }, { "status": "DELIVERED", "timestamp": "2025-01-15T08:30:15Z", "description": "Message delivered to recipient" } ] }, "error": null, "meta": { "request_id": "req_xyz789", "timestamp": "2025-01-15T08:30:16Z", "version": "v3" } } ``` **Don't poll for status updates in production.** Use webhooks instead. Polling is only recommended for debugging or one-off checks. ### Method 3: Dashboard View message status in the [Sent Dashboard](https://app.sent.dm/dashboard/activities): 1. Go to the **Activities** page 2. Filter by message status, date range, or template 3. Click on any message for detailed information 4. View delivery timeline and any errors ## Status Reference A status is terminal when no further status events follow it. The one exception is `READ`, which can still arrive after a terminal `DELIVERED` on WhatsApp and RCS. | Status | Direction | Terminal | Description | Next States | |--------|-----------|:--------:|-------------|-------------| | `QUEUED` | Outbound | No | Message accepted, awaiting processing | `ROUTED`, `SCHEDULED`, `FILTERED`, `BLOCKED`, `FAILED` | | `ROUTED` | Outbound | No | Message assigned to a carrier or provider | `SENT`, `FAILED` | | `SENT` | Outbound | No | Dispatched to channel provider | `DELIVERED`, `FAILED` | | `DELIVERED` | Outbound | Yes | Confirmed delivery to device | `READ` (WhatsApp and RCS) | | `READ` | Outbound | Yes | Recipient opened the message (WhatsApp and RCS). Follows `DELIVERED` | None | | `FAILED` | Outbound | Yes | A send was attempted and failed downstream: carrier reject, network error, invalid number, or no route matched. **The only status that counts against your deliverability rate.** | None | | `FILTERED` | Outbound | Yes | A policy gate suppressed the message before dispatch: the recipient opted out, the number is on your suppression list, or a routing rule denied the send. Expected behavior, not a failure, so it does not count against your deliverability rate. | None | | `BLOCKED` | Outbound | Yes | An account precondition stopped the message before send evaluation: insufficient balance, an unmet onboarding quota, or a template that is not approved for sending. Does not count against your deliverability rate. | None | | `SCHEDULED` | Outbound | No | The send landed inside the recipient's quiet hours, so it is held instead of failed. Sent releases it automatically when the window closes. | `ROUTED` | | `RECEIVED` | Inbound | Yes | Inbound message received from a contact | None | Every status change fires a matching webhook event, including `message.filtered`, `message.blocked`, and `message.scheduled`. See the [Events Reference](/start/webhooks/event-types) for the full status catalog and every webhook payload shape. ## Direction Field Every message retrieved via `retrieveStatus` includes a `direction` field: | Value | Meaning | |-------|---------| | `OUTBOUND` | Message sent by you to a contact | | `INBOUND` | Message received from an end user (such as a reply, or STOP/START/HELP opt-out keywords) | ```typescript const status = await client.messages.retrieveStatus('8ba7b830-9dad-11d1-80b4-00c04fd430c8'); console.log(status.data.direction); // "OUTBOUND" | "INBOUND" ``` Inbound messages include opt-out/opt-in keyword responses (STOP/START/HELP on SMS), general SMS replies, and WhatsApp replies. They have a `direction` of `"INBOUND"` and a `status` of `"RECEIVED"`. Subscribe to `message.received` webhooks to be notified in real time when a contact sends you a message on any channel. See [Two-Way Conversations](/start/guides/two-way-conversations) for inbound handling. ## Handling Failed Messages When a send fails downstream, Sent fires `message.failed`. The payload reports the status but not the cause: ```json { "field": "message", "event": "message.failed", "timestamp": "2025-01-15T08:30:15Z", "payload": { "updated_at": "2025-01-15T08:30:15Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "message_status": "FAILED", "channel": "sms" } } ``` ### Common Failure Reasons | Error Code | Description | Action | |------------|-------------|--------| | `VALIDATION_002` | Phone number format issue | Verify E.164 format | | `ERR_CONSENT_BLOCKED` | Per-message consent block: recipient has `opt_out = true` or is on the phone-channel suppression list. The message is finalized as `FILTERED` (asynchronous, surfaces on `message.filtered`) | Suppress the contact and stop further sends until renewed consent | | `BUSINESS_007` | Channel not available for this contact | Switch channel or rely on the configured channel fallback | | `BUSINESS_008` | Carrier rejected message | Check content compliance | | `BUSINESS_005` | Template not approved | Wait for approval or use a different template | | `BUSINESS_003` | Account balance low | Add funds | | `BUSINESS_002` | Rate limit exceeded | Implement backoff | ## Suppressed and Held Messages `FILTERED` and `BLOCKED` are terminal non-delivery outcomes. Unlike `FAILED`, no send attempt ever reached a carrier, so neither counts against your deliverability rate and neither is retried: - `FILTERED` fires `message.filtered`. A policy gate suppressed the send: the recipient opted out, the number is on your phone-channel suppression list, or routing rules denied every candidate route. - `BLOCKED` fires `message.blocked`. An account precondition stopped the send before evaluation: insufficient balance, an unmet onboarding quota, or a template that is not approved for sending. `SCHEDULED` is a hold rather than an outcome. The send landed inside the recipient's quiet hours, so Sent defers it, fires `message.scheduled`, and releases it automatically when the window closes. The message then continues through the normal pipeline, so `message.routed`, `message.sent`, and `message.delivered` follow. All three use the same payload shape as any other outbound status event: ```json { "field": "message", "event": "message.filtered", "timestamp": "2025-01-15T08:30:02Z", "payload": { "updated_at": "2025-01-15T08:30:02Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "message_status": "FILTERED", "channel": "sms" } } ``` Sent records the internal error code and reason for a filtered, blocked, or failed message, but does not return either one in API responses or webhook payloads, and it does not expose the release time of a scheduled message. Open the message in the [Sent Dashboard](https://app.sent.dm/dashboard/activities), or contact [support@sent.dm](mailto:support@sent.dm) with the message ID, if you need the exact cause. ## Best Practices ### 1. Implement Idempotency Webhooks may be delivered multiple times. Handle this gracefully: ```typescript async function handleWebhook(eventData: any) { const { message_id, message_status } = eventData.payload; // Check if already processed const existing = await db.messages.findById(message_id); if (existing?.status === message_status) { return; // Already at this status — skip } // Process update await db.messages.update(message_id, { status: message_status }); } ``` ### 2. Queue Webhook Processing Don't do heavy work in the webhook handler: ```typescript app.post('/webhooks/sent', async (req, res) => { // Acknowledge immediately res.sendStatus(200); // Queue for background processing await queue.add('process-webhook', req.body); }); // Worker processes in background queue.process('process-webhook', async (job) => { await processWebhookEvent(job.data); }); ``` ### 3. Handle Late Deliveries Some messages may be delivered hours later (for example, when the device is offline): ```typescript if (message_status === 'DELIVERED') { const sentAt = new Date(message.sent_at); // Your stored send time const deliveredAt = new Date(eventData.timestamp); const delayHours = (deliveredAt - sentAt) / (1000 * 60 * 60); if (delayHours > 1) { console.log(`Late delivery: ${delayHours} hours`); } } ``` ### 4. Monitor Delivery Rates Leave `FILTERED` and `BLOCKED` out of the deliverability denominator. They represent policy suppression, not delivery failures, so only `FAILED` counts against the rate. `SCHEDULED` messages have not reached an outcome yet, so leave those out too until they are released. ```typescript // Daily delivery rate: exclude FILTERED, BLOCKED, SCHEDULED, RECEIVED from the denominator const stats = await db.messages.aggregate([ { $match: { createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, direction: 'OUTBOUND' } }, { $group: { _id: '$status', count: { $sum: 1 } } } ]); const byStatus = Object.fromEntries(stats.map(s => [s._id, s.count])); const delivered = byStatus['DELIVERED'] || 0; // Denominator: only statuses that represent a send attempt const denominator = ['QUEUED', 'ROUTED', 'SENT', 'DELIVERED', 'READ', 'FAILED'] .reduce((sum, s) => sum + (byStatus[s] || 0), 0); const deliveryRate = denominator > 0 ? (delivered / denominator) * 100 : 0; console.log(`Delivery rate: ${deliveryRate.toFixed(1)}%`); console.log(`Filtered (policy suppressed): ${byStatus['FILTERED'] || 0}`); console.log(`Blocked (account-level gate): ${byStatus['BLOCKED'] || 0}`); ``` ## Read Receipts (WhatsApp & RCS) WhatsApp and RCS both support read receipts when the recipient opens the message. The `message.read` event is fired for both channels. Check the `channel` field in the payload to distinguish them: ```json { "field": "message", "event": "message.read", "timestamp": "2025-01-15T09:15:30Z", "payload": { "updated_at": "2025-01-15T09:15:30Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "message_status": "READ", "channel": "rcs" } } ``` Read receipts are available for WhatsApp (when the recipient has read receipts enabled in privacy settings) and for RCS. SMS has no read receipt equivalent. ## Inbound Messages (`message.received`) When a contact sends a message to one of your provisioned numbers (a reply, or a keyword like STOP/START/HELP), Sent fires a `message.received` webhook whose payload shape differs from outbound status events. The [Two-Way Conversations guide](/start/guides/two-way-conversations#receiving-inbound-messages-via-webhook) owns inbound handling: storage, keyword and opt-out processing, auto-replies, and handler examples. The inbound payload shape is in the [Events Reference](/start/webhooks/event-types). The inbound message is also stored in your message log with `direction: "INBOUND"` and `status: "RECEIVED"`. You can retrieve it via `GET /v3/messages/{id}` like any outbound message. ## Troubleshooting ### Webhook not receiving events? - Verify webhook URL is accessible from the internet - Check that your endpoint returns 2xx status - Review webhook delivery logs in the dashboard - Verify the webhook is configured for the correct event types ### Status stuck in `QUEUED`? - Normal for first few seconds - Check if account has sufficient balance - Verify KYC is approved - Contact Sent if stuck > 5 minutes ### Status stuck in `SCHEDULED`? - The send landed inside the recipient's quiet hours and is held, not lost - Sent releases it automatically when the window closes; no action is required - The release time is not exposed through the API, so wait for the follow-on `message.sent` and `message.delivered` events ### Missing status updates? - Ensure webhook endpoint is responding quickly (< 5 seconds) - Check for duplicate event handling (idempotency) - Review failed webhook deliveries in dashboard --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/opt-out-and-consent.txt TITLE: Handling Opt-Outs and Consent ================================================================================ URL: https://docs.sent.dm/llms/start/guides/opt-out-and-consent.txt How to mirror Sent opt-outs into your own database, set contact opt-out state through the API, detect consent-blocked sends, and apply a stricter send window. # Handling Opt-Outs and Consent This guide shows you how to keep consent state consistent between Sent and your own systems: mirror keyword opt-outs into your database, opt contacts out or back in through the API, detect consent-blocked sends, and apply a send window stricter than the platform's quiet hours. **Prerequisites:** - A Sent API key, passed in the `x-api-key` header - A webhook endpoint subscribed to `message` events (see [Getting Started with Webhooks](/start/webhooks/getting-started)) Sent already enforces the consent gate at send time, so you do not need a pre-send opt-out check of your own. [Compliance & Regulations](/start/advanced/compliance-regulations) describes everything the platform enforces automatically. ## Mirror keyword opt-outs into your database When a contact texts an opt-out keyword, Sent records the opt-out on the contact and fires a `message.received` webhook carrying the raw text. The webhook fires for every inbound message, keywords included. Match the keyword in your handler and update your own subscriber store: ```typescript const OPT_OUT_KEYWORDS = new Set(['STOP', 'CANCEL', 'UNSUBSCRIBE', 'QUIT', 'END']); const OPT_IN_KEYWORDS = new Set(['START', 'UNSTOP', 'SUBSCRIBE']); app.post('/webhooks/sent', async (req, res) => { // Acknowledge first; process after responding. res.sendStatus(200); const { field, event, payload } = req.body; if (field !== 'message' || event !== 'message.received') return; // payload.inbound_number is the contact's phone number. const keyword = (payload.text ?? '').trim().toUpperCase(); if (OPT_OUT_KEYWORDS.has(keyword)) { // Update your own subscriber store. await db.subscribers.update(payload.inbound_number, { messagingConsent: false, optedOutAt: new Date(), }); } else if (OPT_IN_KEYWORDS.has(keyword)) { await db.subscribers.update(payload.inbound_number, { messagingConsent: true, optedOutAt: null, }); } }); ``` If you configured custom keywords in the dashboard (**Compliance → Opt-Out Keywords**), add them to the `OPT_OUT_KEYWORDS` and `OPT_IN_KEYWORDS` sets. Sent matches keywords exactly and case-insensitively: the entire trimmed message body must equal the keyword. Mirror that rule rather than substring-matching. The contact record's `opt_out` field is what Sent checks on every send; when in doubt, read it back with `GET /v3/contacts?phone=...`. ## Opt a contact out or in through the API If you collect opt-outs on other surfaces (a web preference center, support tickets, email unsubscribes), write them to Sent so the send-time gate enforces them everywhere. ### Find the contact Look up the contact by phone number (URL-encode the `+` as `%2B`): ```bash curl "https://api.sent.dm/v3/contacts?phone=%2B15551234567" \ -H "x-api-key: YOUR_API_KEY" ``` The contact ID is in `data.contacts[0].id`. ### Set the opt-out flag ```bash curl -X PATCH "https://api.sent.dm/v3/contacts/8ba7b830-9dad-11d1-80b4-00c04fd430c8" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"opt_out": true}' ``` To restore consent, send `{"opt_out": false}`. Opt a contact back in only when you hold their documented consent; the API does not check how the opt-out was created. Opt-out is per contact, not per channel: setting the flag suppresses SMS, WhatsApp, and RCS alike. ## Detect consent-blocked sends A send to an opted-out contact is still accepted with `202`; the message is then finalized as `FILTERED` with reason code `ERR_CONSENT_BLOCKED`, and a `message.filtered` webhook fires. Treat it as an expected policy outcome: do not retry, and reconcile your local consent state if it disagrees. Extend the same webhook handler: ```typescript // Inside the same webhook handler: if (field === 'message' && event === 'message.filtered') { // payload.outbound_number is the recipient's phone number. FILTERED also // covers routing DENY rules, so confirm the contact's opt-out state // before mirroring it. const resp = await fetch( `https://api.sent.dm/v3/contacts?phone=${encodeURIComponent(payload.outbound_number)}`, { headers: { 'x-api-key': process.env.SENT_API_KEY } }, ); const { data } = await resp.json(); const contact = data?.contacts?.[0]; if (contact?.opt_out) { await db.subscribers.update(contact.format_e164, { messagingConsent: false, optedOutAt: new Date(), }); } } ``` For per-message detail, `GET /v3/messages/{id}` shows the `FILTERED` status; refer to the [Error Catalog](/reference/api/error-catalog) for `ERR_CONSENT_BLOCKED` remediation. ## Apply a stricter send window Sent's quiet-hours gate already holds sends that land inside a protected window for the destination country: the message is parked as `SCHEDULED` and released automatically, with a `message.scheduled` webhook on the hold. If your legal review requires a stricter window (for example, the US TCPA limits telephone solicitations to 8 AM–9 PM recipient local time, and several states go further), gate the send in your own scheduler: ```typescript // TCPA window: 8 AM-9 PM recipient local time (47 CFR § 64.1200(c)(1)). // This check deliberately keeps a one-hour buffer inside the federal window // (sending only 9 AM-8 PM) to absorb clock skew and stricter state rules. function isInsideSendWindow(timeZone: string, now = new Date()): boolean { const hour = Number( new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', hourCycle: 'h23', }).format(now), ); return hour >= 9 && hour < 20; } if (isInsideSendWindow(subscriber.timeZone)) { await sendMessage(subscriber); } else { await queueForNextWindow(subscriber); } ``` If the platform's quiet-hours rules are sufficient for you, skip this section and subscribe to `message.scheduled` webhooks to observe holds. ## Verify your integration - Set `opt_out: true` on a test contact and send it a message: the API returns `202`, and `GET /v3/messages/{id}` shows `FILTERED` shortly after. - Text `STOP` to your number from a test phone: a `message.received` webhook arrives with the text, and the contact's `opt_out` flag reads `true` on the next `GET /v3/contacts/{id}`. - Text `START` from the same phone: the flag returns to `false`. ## Related pages - [Two-Way Conversations](/start/guides/two-way-conversations): keyword matching rules, auto-replies, and cross-channel opt-out - [Compliance & Regulations](/start/advanced/compliance-regulations): what Sent enforces automatically, plus per-region regulation summaries - [Events Reference](/start/webhooks/event-types): full webhook payloads for `message.received` and `message.filtered` - [Managing Contacts](/start/guides/managing-contacts): the full contact API surface ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/retrying-requests-safely.txt TITLE: How to retry Sent API requests safely ================================================================================ URL: https://docs.sent.dm/llms/start/guides/retrying-requests-safely.txt Retry Sent API v3 requests without duplicating messages or contacts: derive stable idempotency keys, reuse them across retries, and resolve conflicts. # How to retry Sent API requests safely This guide shows you how to retry failed Sent API v3 mutations (after timeouts, network drops, or 5xx errors) without sending the same message or creating the same resource twice. It assumes you know the [idempotency contract](/reference/api/idempotency): key format, 24-hour expiry, and replay semantics. The pattern has three parts: derive a stable key for the operation, reuse that exact key on every retry attempt, and handle the conflict case when two attempts overlap. ## Derive the key from a business identifier Generate the key once per logical operation, from identifiers your system already tracks. A retry must present the same key, so the key cannot depend on when or how the attempt happens: ```typescript // Good: derived from the business operation — any retry reproduces it const key = `payment-${userId}-${invoiceId}`; // Good: derived from a client-generated request ID stored with the operation const key = `contact-create-${clientRequestId}`; // Bad: static key (blocks every later operation of this type for 24 hours) const key = 'create-message'; // Bad: fresh random value per attempt (each retry becomes a new operation) const key = crypto.randomUUID(); ``` ```python # Good: derived from the business operation — any retry reproduces it key = f"payment-{user_id}-{invoice_id}" # Good: derived from a client-generated request ID stored with the operation key = f"contact-create-{client_request_id}" # Bad: static key (blocks every later operation of this type for 24 hours) key = "create-message" # Bad: fresh random value per attempt (each retry becomes a new operation) key = str(uuid.uuid4()) ``` ```go // Good: derived from the business operation — any retry reproduces it key := fmt.Sprintf("payment-%s-%s", userID, invoiceID) // Good: derived from a client-generated request ID stored with the operation key := fmt.Sprintf("contact-create-%s", clientRequestID) // Bad: static key (blocks every later operation of this type for 24 hours) key := "create-message" // Bad: fresh random value per attempt (each retry becomes a new operation) key := uuid.New().String() ``` If an operation can legitimately repeat (the same invoice reminder sent weekly), include the occurrence in the identifier: for example `invoice-1234-reminder-2026-07`, not a timestamp captured at send time. A random UUID is only safe if you generate it when the operation is created and persist it alongside the operation. A UUID generated inside the send path produces a different key on each retry and defeats idempotency. ## Reuse the same key across retry attempts Send the key on the first attempt and on every retry. Retry 5xx and network errors with exponential backoff. Don't retry 4xx errors; they fail the same way every time until you fix the request: ```typescript async function sendMessageWithRetry( payload: MessagePayload, operationId: string, // stable, caller-supplied (e.g. an order ID) maxRetries = 3 ): Promise { const idempotencyKey = `msg-${operationId}`; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch('https://api.sent.dm/v3/messages', { method: 'POST', headers: { 'x-api-key': API_KEY, 'Idempotency-Key': idempotencyKey, // Same key on retry 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (response.ok) { return await response.json(); } // Don't retry on 4xx errors (client errors) if (response.status >= 400 && response.status < 500) { throw new Error(`Client error: ${response.status}`); } // Retry on 5xx or network errors if (attempt < maxRetries) { await sleep(Math.pow(2, attempt) * 1000); } } catch (error) { if (attempt === maxRetries) throw error; } } throw new Error('Max retries exceeded'); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python import time import requests def send_message_with_retry( payload: dict, operation_id: str, # stable, caller-supplied (e.g. an order ID) max_retries: int = 3, ) -> dict: """Send message with idempotency and retry logic.""" idempotency_key = f"msg-{operation_id}" for attempt in range(1, max_retries + 1): try: response = requests.post( 'https://api.sent.dm/v3/messages', headers={ 'x-api-key': API_KEY, 'Idempotency-Key': idempotency_key, # Same key on retry 'Content-Type': 'application/json' }, json=payload ) if response.ok: return response.json() # Don't retry on 4xx errors (client errors) if 400 <= response.status_code < 500: raise Exception(f"Client error: {response.status_code}") # Retry on 5xx or network errors if attempt < max_retries: time.sleep(2 ** attempt) except Exception as e: if attempt == max_retries: raise e raise Exception('Max retries exceeded') ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "time" ) // operationID is stable and caller-supplied (e.g. an order ID) func sendMessageWithRetry(payload map[string]interface{}, operationID string, maxRetries int) (map[string]interface{}, error) { idempotencyKey := fmt.Sprintf("msg-%s", operationID) for attempt := 1; attempt <= maxRetries; attempt++ { body, _ := json.Marshal(payload) req, _ := http.NewRequest( "POST", "https://api.sent.dm/v3/messages", bytes.NewBuffer(body), ) req.Header.Set("x-api-key", API_KEY) req.Header.Set("Idempotency-Key", idempotencyKey) // Same key on retry req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { if attempt == maxRetries { return nil, err } time.Sleep(time.Duration(1<= 200 && resp.StatusCode < 300 { var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) return result, nil } // Don't retry on 4xx errors (client errors) if resp.StatusCode >= 400 && resp.StatusCode < 500 { return nil, fmt.Errorf("client error: %d", resp.StatusCode) } // Retry on 5xx or network errors if attempt < maxRetries { time.Sleep(time.Duration(1< { try { return await apiRequest(url, payload, key); } catch (error: any) { if (error.code === 'CONFLICT_001') { // Wait for the original request to complete await sleep(500); return await apiRequest(url, payload, key); } throw error; } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python import time def send_with_idempotency(url: str, payload: dict, key: str) -> dict: """Send request with handling for concurrent idempotent requests.""" try: return api_request(url, payload, key) except Exception as e: error_code = getattr(e, 'code', None) if error_code == 'CONFLICT_001': # Wait for the original request to complete time.sleep(0.5) return api_request(url, payload, key) raise ``` ```go package main import ( "time" ) func sendWithIdempotency(url string, payload map[string]interface{}, key string) (map[string]interface{}, error) { result, err := apiRequest(url, payload, key) if err != nil { if err.Error() == "CONFLICT_001" { // Wait for the original request to complete time.Sleep(500 * time.Millisecond) return apiRequest(url, payload, key) } return nil, err } return result, nil } ``` ## Wrap the pattern in a reusable client For codebases with many call sites, centralize the header handling and replay detection in one client class. The caller supplies the operation identifier, so keys stay stable across retries and are easy to trace in logs: ```typescript class IdempotentClient { constructor(private apiKey: string, private baseUrl = 'https://api.sent.dm') {} async request( method: string, endpoint: string, payload?: object, options: { idempotencyKey?: string } = {} ) { const headers: Record = { 'x-api-key': this.apiKey, 'Content-Type': 'application/json' }; // Add idempotency key if provided if (options.idempotencyKey) { headers['Idempotency-Key'] = options.idempotencyKey; } const response = await fetch(`${this.baseUrl}${endpoint}`, { method, headers, body: payload ? JSON.stringify(payload) : undefined }); const data = await response.json(); // Check if this was a replay if (response.headers.get('Idempotent-Replayed')) { console.log('Idempotent replay detected'); console.log('Original request ID:', response.headers.get('X-Original-Request-Id')); } if (!data.success) { throw new Error(`${data.error.code}: ${data.error.message}`); } return data.data; } // Send a message; the caller supplies a stable operation ID async sendMessage( to: string[], templateId: string, operationId: string, parameters?: Record ) { return this.request('POST', '/v3/messages', { to, template: { id: templateId, parameters } }, { idempotencyKey: `msg-${operationId}` }); } } // Usage const client = new IdempotentClient(process.env.SENT_API_KEY!); // The order ID makes the send idempotent per order await client.sendMessage( ['+1234567890'], 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 'order-confirmation-8412', { name: 'John' } ); // Or provide your own key for other operations await client.request('POST', '/v3/contacts', { phone_number: '+1234567890' }, { idempotencyKey: 'import-user-12345' }); ``` ```python import requests from typing import Any, Dict, List, Optional class IdempotentClient: def __init__(self, api_key: str, base_url: str = 'https://api.sent.dm'): self.api_key = api_key self.base_url = base_url def request( self, method: str, endpoint: str, payload: Optional[Dict] = None, idempotency_key: Optional[str] = None ) -> Dict[str, Any]: headers = { 'x-api-key': self.api_key, 'Content-Type': 'application/json' } if idempotency_key: headers['Idempotency-Key'] = idempotency_key response = requests.request( method, f'{self.base_url}{endpoint}', headers=headers, json=payload ) # Check for replay if response.headers.get('Idempotent-Replayed'): print('Idempotent replay detected') print(f'Original request ID: {response.headers.get("X-Original-Request-Id")}') data = response.json() if not data.get('success'): raise Exception(f"{data['error']['code']}: {data['error']['message']}") return data['data'] def send_message( self, to: List[str], template_id: str, operation_id: str, parameters: Optional[Dict[str, str]] = None ) -> Dict[str, Any]: """Send a message; the caller supplies a stable operation ID.""" return self.request('POST', '/v3/messages', { 'to': to, 'template': {'id': template_id, 'parameters': parameters or {}} }, idempotency_key=f"msg-{operation_id}") def create_contact(self, phone_number: str, operation_id: str) -> Dict[str, Any]: """Create a contact; the caller supplies a stable operation ID.""" return self.request('POST', '/v3/contacts', { 'phone_number': phone_number }, idempotency_key=f"contact-{operation_id}") # Usage client = IdempotentClient(api_key='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') # The order ID makes the send idempotent per order message = client.send_message( ['+1234567890'], 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', 'order-confirmation-8412', {'customer_name': 'John'} ) # The CSV row makes each import idempotent per row contact = client.create_contact('+1234567890', operation_id='import-csv-row-42') ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) type IdempotentClient struct { APIKey string BaseURL string } func NewIdempotentClient(apiKey string) *IdempotentClient { return &IdempotentClient{ APIKey: apiKey, BaseURL: "https://api.sent.dm", } } func (c *IdempotentClient) Request( method string, endpoint string, payload interface{}, idempotencyKey string, ) (map[string]interface{}, error) { var body []byte if payload != nil { body, _ = json.Marshal(payload) } req, err := http.NewRequest( method, c.BaseURL+endpoint, bytes.NewBuffer(body), ) if err != nil { return nil, err } req.Header.Set("x-api-key", c.APIKey) req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // Check for replay if resp.Header.Get("Idempotent-Replayed") == "true" { fmt.Println("Idempotent replay detected") fmt.Printf("Original request ID: %s\n", resp.Header.Get("X-Original-Request-Id")) } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } if success, ok := result["success"].(bool); !ok || !success { errorData := result["error"].(map[string]interface{}) return nil, fmt.Errorf("%s: %s", errorData["code"], errorData["message"]) } return result["data"].(map[string]interface{}), nil } // SendMessage sends a message; the caller supplies a stable operation ID func (c *IdempotentClient) SendMessage( to []string, templateID string, operationID string, parameters map[string]string, ) (map[string]interface{}, error) { payload := map[string]interface{}{ "to": to, "template": map[string]interface{}{ "id": templateID, "parameters": parameters, }, } return c.Request("POST", "/v3/messages", payload, fmt.Sprintf("msg-%s", operationID)) } // Usage func main() { client := NewIdempotentClient(os.Getenv("SENT_API_KEY")) // The order ID makes the send idempotent per order message, err := client.SendMessage( []string{"+1234567890"}, "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "order-confirmation-8412", map[string]string{"customer_name": "John"}, ) if err != nil { panic(err) } fmt.Printf("Messages queued: %v\n", message["recipients"]) // Or provide your own key for other operations contact, err := client.Request( "POST", "/v3/contacts", map[string]string{"phone_number": "+1234567890"}, "import-user-12345", ) if err != nil { panic(err) } fmt.Printf("Contact created: %v\n", contact["id"]) } ``` ## Verify the behavior Confirm your retries are safe by forcing a replay: send the same request twice with the same key. The second response returns the original status and body with the `Idempotent-Replayed: true` header, and `X-Original-Request-Id` names the first request. If the second call creates a second resource instead, the key changed between attempts. Check that it derives only from stable identifiers. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | | `400` with `VALIDATION_007` | Key contains characters outside `A-Z a-z 0-9 - _` or exceeds 255 chars | Sanitize the identifiers you build keys from | | `409` with `CONFLICT_001` | Duplicate sent while the original is still executing | Wait briefly, retry with the same key | | Second request returns stale data for a different payload | Key reused for a different operation; the API replays the cached response without comparing bodies | Derive keys so distinct operations never share one | | Retry executed as a new operation | More than 24 hours passed and the key expired, or the key changed between attempts | Retry within 24 hours; derive keys from stable identifiers | ## Related - Refer to the [Idempotency reference](/reference/api/idempotency) for the full contract: key format, caching rules, and response headers. - [How to handle Sent API errors](/start/guides/handling-api-errors) - [How to handle Sent API rate limits](/start/guides/handling-rate-limits) ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/sending-messages.txt TITLE: Sending Messages ================================================================================ URL: https://docs.sent.dm/llms/start/guides/sending-messages.txt How to send SMS, WhatsApp, and RCS messages with the Sent API: template sends, free-form text without a template, channel selection, and error handling. # Sending Messages This guide shows you how to send SMS, WhatsApp, and RCS messages with the Sent API, using a template or free-form text. ## Overview A **Message** in Sent represents a single communication sent to a contact through a specific channel (SMS, WhatsApp, or RCS). This guide covers the core aspects of message sending. ## The Message Lifecycle ## Sending Methods ### 1. Send to Phone Number The simplest approach - send directly to any phone number: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "customerName": "John Doe", "orderNumber": "#12345" } } }' ``` ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', parameters: { customerName: 'John Doe', orderNumber: '#12345' } } }); console.log(`Message ID: ${response.data.recipients[0].message_id}`); ``` ```python from sent_dm import SentDm client = SentDm() response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "customerName": "John Doe", "orderNumber": "#12345" } } ) print(f"Message ID: {response.data.recipients[0].message_id}") ``` ```go import ( "context" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) client := sentdm.NewClient() response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Parameters: map[string]interface{}{ "customerName": "John Doe", "orderNumber": "#12345", }, }, }) ``` ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; SentDmClient client = SentDmOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("customerName", JsonValue.from("John Doe")) .putAdditionalProperty("orderNumber", JsonValue.from("#12345")) .build()) .build()) .build(); var response = client.messages().send(params); System.out.println("Sent: " + response.data().recipients().get(0).messageId()); ``` ```csharp using Sentdm; using Sentdm.Models.Messages; using System.Collections.Generic; SentDmClient client = new(); MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Parameters = new Dictionary { { "customerName", "John Doe" }, { "orderNumber", "#12345" } } } }; var response = await client.Messages.Send(parameters); Console.WriteLine($"Sent: {response.Data.Recipients[0].MessageId}"); ``` ```php messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'parameters' => [ 'customerName' => 'John Doe', 'orderNumber' => '#12345' ] ] ); var_dump($result->data->recipients[0]->message_id); ``` ```ruby require "sentdm" sent_dm = Sentdm::Client.new result = sent_dm.messages.send( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", parameters: { customerName: "John Doe", orderNumber: "#12345" } } ) puts result.data.recipients[0].message_id ``` ### 2. Send to Multiple Recipients Send the same message to multiple recipients in one request: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890", "+1987654321", "+1555555555"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "announcement": "Our store is now open!" } } }' ``` ```typescript const response = await client.messages.send({ to: ['+1234567890', '+1987654321', '+1555555555'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', parameters: { announcement: 'Our store is now open!' } } }); console.log(`Sent to ${response.data.recipients.length} recipients`); ``` ```python response = client.messages.send( to=["+1234567890", "+1987654321", "+1555555555"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": { "announcement": "Our store is now open!" } } ) print(f"Sent to {len(response.data.recipients)} recipients") ``` ```go response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890", "+1987654321", "+1555555555"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Parameters: map[string]interface{}{ "announcement": "Our store is now open!", }, }, }) fmt.Printf("Sent to %d recipients\n", len(response.Data.Recipients)) ``` ```java import dm.sent.models.messages.MessageSendParams; import dm.sent.core.JsonValue; MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addTo("+1987654321") .addTo("+1555555555") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("announcement", JsonValue.from("Our store is now open!")) .build()) .build()) .build(); var response = client.messages().send(params); System.out.println("Sent to " + response.data().recipients().size() + " recipients"); ``` ```csharp using Sentdm.Models.Messages; MessageSendParams parameters = new() { To = new List { "+1234567890", "+1987654321", "+1555555555" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Parameters = new Dictionary { { "announcement", "Our store is now open!" } } } }; var response = await client.Messages.Send(parameters); Console.WriteLine($"Sent to {response.Data.Recipients.Count} recipients"); ``` ```php $result = $client->messages->send( to: ['+1234567890', '+1987654321', '+1555555555'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'parameters' => [ 'announcement' => 'Our store is now open!' ] ] ); echo "Sent to " . count($result->data->recipients) . " recipients\n"; ``` ```ruby result = sent_dm.messages.send( to: ["+1234567890", "+1987654321", "+1555555555"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", parameters: { announcement: "Our store is now open!" } } ) puts "Sent to #{result.data.recipients.length} recipients" ``` **Recipient Limit:** A single request can include up to **1000 recipients**. For campaigns larger than 1000, split into multiple requests. Each API call counts as one request toward the 200 req/min rate limit. ## Sending Free-Form Text (Without a Template) To send an ad-hoc message body without creating a template, pass `text` instead of `template`. Provide exactly one of the two: the API rejects a request that includes both, or neither, with a `400` validation error. ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "text": "Thanks for reaching out! Your order shipped this morning." }' ``` ```typescript const response = await client.messages.send({ to: ['+1234567890'], text: 'Thanks for reaching out! Your order shipped this morning.' }); console.log(`Message ID: ${response.data.recipients[0].message_id}`); ``` ```python response = client.messages.send( to=["+1234567890"], text="Thanks for reaching out! Your order shipped this morning." ) print(f"Message ID: {response.data.recipients[0].message_id}") ``` ```go response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Text: sentdm.String("Thanks for reaching out! Your order shipped this morning."), }) ``` ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .text("Thanks for reaching out! Your order shipped this morning.") .build(); var response = client.messages().send(params); ``` ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890" }, Text = "Thanks for reaching out! Your order shipped this morning." }; var response = await client.Messages.Send(parameters); ``` ```php $result = $client->messages->send( to: ['+1234567890'], text: 'Thanks for reaching out! Your order shipped this morning.' ); var_dump($result->data->recipients[0]->message_id); ``` ```ruby result = sent_dm.messages.send( to: ["+1234567890"], text: "Thanks for reaching out! Your order shipped this morning." ) puts result.data.recipients[0].message_id ``` To pin a free-form send to one channel, set `channel` exactly as for template sends; see [Channel Selection Strategies](#channel-selection-strategies). On SMS, a free-form body follows the same segmentation rules as template output; see [SMS encoding and length](/start/concepts/sms-encoding-and-length). The response to a free-form send still includes a `template_id` and `template_name` (`FREE_TEXT_SYS_TEMPLATE`). Free-form messages ride Sent's internal free-text system template, and your text renders verbatim as the message body. ### When You Can Send Free-Form Text Free-form text is for replying inside an existing conversation. The first message to a contact who has never messaged you must use a template. Per channel: | Channel | Free-form text allowed | |---------|------------------------| | SMS | After the contact has sent at least one inbound message on any channel (SMS, RCS, or WhatsApp). No time limit. | | RCS | Same rule as SMS. | | WhatsApp | Only within 24 hours of the contact's latest inbound WhatsApp message (Meta's customer-service window). | | Auto-detect (`channel` omitted or `["sent"]`) | Same prior-inbound rule as SMS and RCS. Sent does not route a free-form send to WhatsApp unless the 24-hour window is open. | Standalone compliance keywords from the contact, such as STOP, START, or HELP, don't count as inbound messages for these rules. For how conversation windows open and reset, see [Two-Way Conversations](/start/guides/two-way-conversations). To start a conversation with a contact who has never replied, use a template; see [Working with Templates](/start/guides/working-with-templates). ### Free-Form Send Errors If the request contains both `text` and `template`, or neither, the API rejects it synchronously: ```json { "success": false, "status": 400, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "template": ["Provide exactly one of 'template' or 'text'"] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" } } ``` If the request is valid but the send doesn't meet the per-channel rules, the API still returns `202 Accepted`. The delivery pipeline enforces the gate, and the message fails asynchronously: - SMS, RCS, or auto-detect with no prior inbound from the contact: status `FAILED` with error code `VALIDATION_004` (`'template' is required`) - WhatsApp outside the 24-hour window: status `FAILED` with error code `WHATSAPP_TEMPLATE_REQUIRED` Check the outcome via `GET /messages/{id}` or webhooks; see [Status Tracking](/start/guides/message-status-tracking). ## Channel Selection Strategies ### Automatic Selection (Default) Let Sent choose the optimal channel by omitting the `channel` field: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } }' ``` ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' } // No channel field - automatic selection }); ``` ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } # No channel parameter - automatic selection ) ``` ```go response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), }, // No channel field - automatic selection }) ``` ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .build()) // No channel - automatic selection .build(); var response = client.messages().send(params); ``` ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } // No channel - automatic selection }; var response = await client.Messages.Send(parameters); ``` ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8' ] // No channel - automatic selection ); ``` ```ruby result = sent_dm.messages.send( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } # No channel - automatic selection ) ``` Sent considers: - Contact's available channels - Historical delivery success rates - Cost optimization - Regional preferences Omitting the field is equivalent to `channel: ["sent"]`, the auto-detect value. Automatic selection is also the only mode with cross-channel fallback: when the preferred channel can't deliver, Sent can route the message over another one. ### Force Specific Channel Override automatic selection by specifying the `channel` field. An explicit channel **pins** each message to that channel and disables cross-channel fallback: if the pinned channel can't deliver to a recipient, the message fails rather than switching channels. The request shape is identical for every channel: only the `channel` value changes. This example pins the send to SMS: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, "channel": ["sms"] }' ``` ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' }, channel: ['sms'] }); ``` ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, channel=["sms"] ) ``` ```go response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Channel: []string{"sms"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), }, }) ``` ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addChannel("sms") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .build()) .build(); var response = client.messages().send(params); ``` ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890" }, Channel = new List { "sms" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8" } }; var response = await client.Messages.Send(parameters); ``` ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8' ], channel: ['sms'] ); ``` ```ruby result = sent_dm.messages.send( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, channel: ["sms"] ) ``` #### Channel Values | `channel` value | Behavior | |-----------------|----------| | Omitted or `["sent"]` | Automatic selection with cross-channel fallback ([the default](#automatic-selection-default)). | | `["sms"]` | Pins every message to SMS. | | `["whatsapp"]` | Pins every message to WhatsApp. Fails for contacts not reachable on WhatsApp. | | `["rcs"]` | Pins every message to RCS. Fails when the recipient's device or carrier doesn't support RCS. | | Multiple values, for example `["whatsapp", "sms", "rcs"]` | Broadcasts on every listed channel: a separate message per (recipient, channel) pair. | Any other value fails request validation with a `400` error. Refer to the [Channel Routing reference](/reference/channel-routing) for the full resolution and fallback rules. A pinned message never falls back to another channel: if the pinned channel can't deliver to a recipient, that message fails. To reach each recipient over the best available channel, omit the `channel` field ([automatic selection](#automatic-selection-default)). #### Broadcasting to Multiple Channels The `channel` array is a **broadcast list, not a fallback order**. When you specify multiple channels, Sent creates a separate message for each (recipient, channel) pair and dispatches them all. Each message gets its own `message_id` and is billed independently: `["whatsapp", "sms", "rcs"]` sends every recipient three messages. ## Response Overview ### Success Response (202 Accepted) ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` **Important:** Store the `message_id` from each recipient object. You'll need it to track delivery status. ### Error Response (4xx/5xx) ```json { "success": false, "status": 402, "error": { "code": "BUSINESS_003", "message": "Account balance is insufficient to send this message", "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` Common error codes: - `RESOURCE_002` - Template not found or not approved - `BUSINESS_003` - Insufficient account balance - `BUSINESS_002` - Rate limit exceeded (200 req/min standard, 10 req/min sensitive) - `VALIDATION_001` - Request validation failed (for example, both or neither of `text` and `template` supplied) - `VALIDATION_002` - Invalid phone number format - `BUSINESS_005` - Template not approved for sending - `BUSINESS_007` - Channel not available for this contact - `BUSINESS_008` - Operation would exceed quota - `WHATSAPP_TEMPLATE_REQUIRED` - Free-form WhatsApp message outside the 24-hour window ## Next Steps --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/sms-length-and-cost.txt TITLE: SMS Length, Segments & Cost ================================================================================ URL: https://docs.sent.dm/llms/start/guides/sms-length-and-cost.txt How Sent counts SMS segments, how segments map to billing, and practical tips to keep messages short and predictable # SMS Length, Segments & Cost On SMS, **length is cost**. A message longer than a single SMS is split into multiple *segments*, and each segment is billed as a separate message. Because the per-message limit depends on the characters you use, the same-looking message can cost one SMS or three. This guide is the practical companion to [SMS Encoding & Message Length](/start/concepts/sms-encoding-and-length), which explains the underlying GSM-7 vs Unicode mechanics. Start there if the terms *GSM-7*, *UCS-2*, or *concatenation* are new. ## Try it: SMS length calculator Type or paste a message to see its encoding, character count, and how many billable segments it becomes. Toggle **character detail** to see exactly which characters are expensive. Things worth trying: - Paste a plain English message just over 160 characters and watch it become 2 segments of 153. - Add a single emoji (😀) and watch the encoding flip to Unicode and the limit drop to 70. - Replace a straight quote `"` with a curly quote `"`: same flip, no visible difference in the text. ## How Sent counts segments Sent computes the segment count from the message body using the standard GSM 03.38 rules (the **same logic used for billing**), so the count you see is the count you're charged for. - The **dashboard** shows the segment count on each message in the activity view (such as "3 parts") for multi-part SMS, with a per-segment breakdown when you expand the message. - The **calculator on this page** applies the same counting rules, so you can check any message body before sending it. - The **API** does not return a segment count: the `GET /v3/messages/{id}` response carries the per-message `price` but no segments field. Segment count is a property of **SMS only**. WhatsApp and RCS are billed per message (or per conversation), not per SMS segment. ## How segments map to cost Each SMS segment is billed as one message. The math follows directly from the [encoding rules](/start/concepts/sms-encoding-and-length): | Your message | Encoding | Segments | Billed as | |---|---|---|---| | "Your code is 1234" (17 chars) | GSM-7 | 1 | 1 SMS | | 200 characters of plain English | GSM-7 | 2 | 2 SMS | | 480 characters of plain English | GSM-7 | 4 | 4 SMS | | "Votre colis est arrivé 📦" | Unicode | 1 | 1 SMS | | 80 characters including one emoji | Unicode | 2 | 2 SMS | A 10-character message with one emoji and a 70-character all-text message both fit in one Unicode segment, but an 80-character message with an emoji is two. The emoji didn't add much length; it changed the *limit*. ## Keep messages short and predictable **The single most common surprise: accidental Unicode.** A message you wrote as pure GSM-7 can silently become Unicode, and lose more than half its per-segment room, because of a character you can't see the difference of: - **Curly quotes** `" " ' '` instead of straight `" '` (auto-inserted by many editors and phones) - **En/em dashes** `– —` instead of a hyphen `-` - **Ellipsis** `…` instead of three dots `...` - Any **emoji** If a message should be GSM-7, normalize these characters before sending. Practical guidance: - **Aim for a single segment.** Under 160 GSM-7 characters (or 70 if the content is inherently Unicode) is the cheapest, most reliable outcome. - **Budget for variables.** A template that's 150 characters with a short name can blow past 160, into a second segment, when the name or a URL is long. Test with realistic, worst-case variable values, not "John." - **Budget for translation.** A message comfortably within 160 GSM-7 characters in English will encode as Unicode (70-character segments) in Arabic, Chinese, Cyrillic, and most non-Latin scripts. The same content can be 1 segment in one language and 3 in another. - **Fewer parts, fewer failures.** Beyond cost, each additional segment is another part that must arrive and be reassembled by the recipient's device and network. Long concatenated messages are marginally more prone to delivery issues than single ones. ## Check the count before sending Segment count is derived purely from the message body, so you can verify it without spending on real delivery: paste the final rendered message (with realistic, worst-case variable values) into the calculator at the top of this page. It applies the same GSM 03.38 rules Sent bills with. After a live send, confirm the billed part count in the dashboard's activity view. ## Related --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/testing-debugging.txt TITLE: Testing & Debugging ================================================================================ URL: https://docs.sent.dm/llms/start/guides/testing-debugging.txt Test and debug your Sent integration by validating sends with sandbox mode, unit-testing webhook handlers, verifying signatures, and tracing failures. # Testing & Debugging Validate your integration, debug issues, and ensure reliable message delivery before going to production. ## Sandbox Mode Use sandbox mode to validate requests without sending real messages or incurring charges. ### Enabling Sandbox Mode Add `"sandbox": true` to the request body to validate without side effects: ```bash curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, "sandbox": true }' ``` ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' }, sandbox: true }); // Sandbox mode returns realistic response without side effects console.log('Status:', response.data.status); console.log('Recipients:', response.data.recipients); console.log('Sandbox mode:', response.headers['X-Sandbox']); ``` ```python response = client.messages.send( to=["+1234567890"], template={"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8"}, sandbox=True ) # Sandbox mode returns realistic response without side effects print(f"Status: {response.data.status}") print(f"Recipients: {response.data.recipients}") ``` ```go response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), }, Sandbox: sentdm.Bool(true), }) ``` ### Sandbox Mode Response The API returns a realistic response (202 Accepted) **without executing side effects**. Check the `X-Sandbox: true` header: ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "welcome_message", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+1234567890", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_test_001", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` ### What Sandbox Mode Validates | Check | Description | |-------|-------------| | **Authentication** | API key is valid | | **Template** | Template exists and is accessible | | **Variables** | All required template parameters provided | | **Recipients** | Phone numbers are valid format | | **Rate Limits** | Request is within rate limits | | **Idempotency** | Key uniqueness (if provided) | Sandbox mode returns 202 Accepted with realistic fake data. No database writes, no messages sent, no external API calls. ## Testing Scenarios ### Unit Testing Test your message sending logic in isolation: ```typescript // messageService.test.ts import { describe, it, expect, vi } from 'vitest'; import { sendWelcomeMessage } from './messageService'; describe('sendWelcomeMessage', () => { it('should send welcome message successfully', async () => { const result = await sendWelcomeMessage('+1234567890', 'John'); expect(result.success).toBe(true); expect(result.messageId).toBeDefined(); }); it('should handle invalid phone number', async () => { const result = await sendWelcomeMessage('invalid', 'John'); expect(result.success).toBe(false); expect(result.error).toContain('phone number'); }); it('should use sandbox mode in development', async () => { process.env.NODE_ENV = 'development'; const result = await sendWelcomeMessage('+1234567890', 'John'); expect(result.sandbox).toBe(true); }); }); ``` ### Integration Testing Test the full flow with the actual API: ```typescript // integration.test.ts describe('Sent API Integration', () => { it('should send and track message delivery', async () => { // Send message const sendResult = await client.messages.send({ to: [TEST_PHONE_NUMBER], template: { id: TEST_TEMPLATE_ID }, sandbox: true }); expect(sendResult.data.recipients[0].message_id).toBeDefined(); // Query status const messageId = sendResult.data.recipients[0].message_id; const statusResult = await client.messages.retrieveStatus(messageId); expect(statusResult.data.status).toBe('QUEUED'); }); }); ``` ### Load Testing Test your integration under load: ```typescript // loadTest.ts async function loadTest() { const concurrency = 10; const totalMessages = 100; const startTime = Date.now(); // Send messages in batches for (let i = 0; i < totalMessages; i += concurrency) { const batch = Array(concurrency).fill(null).map((_, j) => client.messages.send({ to: [TEST_PHONE_NUMBER], template: { id: TEST_TEMPLATE_ID }, sandbox: true }) ); await Promise.all(batch); console.log(`Sent batch ${i / concurrency + 1}`); } const duration = Date.now() - startTime; const rate = totalMessages / (duration / 1000); console.log(`Rate: ${rate.toFixed(2)} messages/second`); } ``` ## Debugging Failed Messages ### 1. Check Response Details ```typescript try { const response = await client.messages.send({...}); } catch (error) { console.log('Status:', error.status); console.log('Code:', error.code); console.log('Message:', error.message); console.log('Request ID:', error.meta?.request_id); // For support tickets } ``` ### 2. Enable Debug Logging ```typescript const client = new SentDm({ logLevel: 'debug' }); // Or via environment variable process.env.SENT_DM_LOG = 'debug'; ``` ### 3. Use the Dashboard 1. Go to [Activities](https://app.sent.dm/dashboard/activities) 2. Find the failed message 3. Click for detailed error information 4. View request/response payloads ### 4. Common Issues and Solutions | Symptom | Possible Cause | Solution | |---------|---------------|----------| | `401` `AUTH_001` | Invalid API key | Verify key in dashboard | | `400` `VALIDATION_001` | Invalid request format | Check request body | | `404` `RESOURCE_002` | Template not found | Verify template ID | | `422` `VALIDATION_002` | Invalid phone number | Use E.164 format | | Message finalized as `BLOCKED` (`message.blocked` webhook) | Insufficient balance or another account-level precondition | Add funds to account | | `429` `BUSINESS_002` | Rate limit exceeded | Implement backoff (200 req/min std) | | Message stuck in "QUEUED" | KYC pending | Complete verification | | Webhook not received | Invalid URL | Verify endpoint returns 2xx | ## Webhook Testing ### Local Development with ngrok ```bash # Start your local server npm run dev # Running on http://localhost:3000 # Create tunnel npx ngrok http 3000 # Use the HTTPS URL in webhook configuration # https://abc123.ngrok.io/webhooks/sent ``` ### Webhook Testing Tools Test webhook handlers without sending real messages. For the production handler implementations under test, refer to [Message Status Tracking](/start/guides/message-status-tracking) (status events) and [Two-Way Conversations](/start/guides/two-way-conversations) (inbound messages): ```typescript // webhook.test.ts const mockEvent = { field: 'message', event: 'message.delivered', timestamp: new Date().toISOString(), payload: { updated_at: new Date().toISOString(), account_id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', message_id: '8ba7b830-9dad-11d1-80b4-00c04fd430c8', template_id: '9ba7b840-9dad-11d1-80b4-00c04fd430c8', template_name: 'order_confirmation', outbound_number: '+1987654321', message_status: 'DELIVERED', channel: 'sms' } }; describe('Webhook Handler', () => { it('should handle message delivered event', async () => { await request(app) .post('/webhooks/sent') .send(mockEvent) .expect(200); // Verify database was updated const message = await db.messages.findById(mockEvent.payload.message_id); expect(message.status).toBe('DELIVERED'); }); it('should verify webhook signature', async () => { const rawBody = JSON.stringify(mockEvent); const webhookId = '550e8400-e29b-41d4-a716-446655440000'; const timestamp = Math.floor(Date.now() / 1000).toString(); // HMAC-SHA256 over `${webhookId}.${timestamp}.${rawBody}`, formatted as `v1,{base64}` const signature = generateSignature(webhookId, timestamp, rawBody, WEBHOOK_SECRET); await request(app) .post('/webhooks/sent') .set('X-Webhook-ID', webhookId) .set('X-Webhook-Timestamp', timestamp) .set('X-Webhook-Signature', signature) .set('Content-Type', 'application/json') .send(rawBody) .expect(200); }); }); ``` Refer to [Webhook Security](/start/webhooks/signature-verification) for the full signature scheme and a verification implementation. ### Replay Webhook Events ```bash # Using curl to replay a webhook curl -X POST http://localhost:3000/webhooks/sent \ -H "Content-Type: application/json" \ -H "X-Webhook-ID: test-event-id" \ -H "X-Webhook-Timestamp: $(date +%s)" \ -H "X-Webhook-Signature: v1," \ -d '{ "field": "message", "event": "message.delivered", "timestamp": "2025-01-15T08:30:15Z", "payload": { "updated_at": "2025-01-15T08:30:15Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "message_status": "DELIVERED", "channel": "sms" } }' ``` ## Test Environment ### Isolated Testing Sent does not provide a separate test environment, and KYC verification requires real business details: placeholder details lead to a rejected verification. Follow the supported process in [Account Setup](/start/quickstart/account-setup), then keep test traffic isolated on your verified account: 1. Create a dedicated API key for development so you can rotate or revoke it without touching production. See [API keys](/start/guides/api-keys). 2. Send with `"sandbox": true` to validate requests without dispatching messages or incurring charges. 3. For end-to-end delivery tests, send real (billed) messages to phone numbers your team controls. Sandbox requests never dispatch a message, so any validly formatted E.164 recipient works. Sent has no reserved test phone numbers with simulated delivery outcomes. ## Monitoring in Production ### Health Checks ```typescript // healthcheck.ts async function healthCheck() { try { // Test API connectivity const response = await client.templates.list({ limit: 1 }); return { status: 'healthy', api: 'connected', timestamp: new Date().toISOString() }; } catch (error) { return { status: 'unhealthy', api: 'disconnected', error: error.message }; } } ``` ### Metrics to Track ```typescript // Track key metrics metrics.histogram('message_send_duration'); metrics.counter('message_send_total', { status: 'success' }); metrics.counter('message_send_total', { status: 'failed' }); metrics.gauge('messages_queued'); ``` ## Troubleshooting Checklist When something isn't working: - [ ] Check API key is correct and active - [ ] Verify KYC is approved - [ ] Confirm account has sufficient balance - [ ] Check template exists and is approved - [ ] Validate phone number format (E.164) - [ ] Review rate limit status - [ ] Check webhook endpoint is responding 200 - [ ] Enable debug logging - [ ] Test in sandbox mode first - [ ] Review dashboard activity logs ## Support Resources If you're stuck: 1. **Check the [Error Catalog](/reference/api/error-catalog)** - Detailed error codes 2. **Review [Troubleshooting Guide](/start/reference-guides/troubleshooting)** - Common issues 3. **Contact Support** - email [support@sent.dm](mailto:support@sent.dm) with: - Request ID (from error response) - Timestamp of issue - Code snippet (with API keys removed) - Expected vs actual behavior --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/testing-with-sandbox-mode.txt TITLE: Testing with Sandbox Mode ================================================================================ URL: https://docs.sent.dm/llms/start/guides/testing-with-sandbox-mode.txt How to test a Sent integration with sandbox mode: assert against real API responses, run CI suites without sending messages, and reproduce failures safely. # Testing with Sandbox Mode This guide shows you how to test a Sent integration against the live API without sending messages: in your test suite, in CI, and when reproducing a production failure. It assumes you have a working integration and a valid API key. For the exact behavior of the `sandbox` field and the endpoints that support it, refer to the [sandbox mode reference](/reference/api/test-mode). ## Assert Against Real Responses Add `"sandbox": true` to the request body in your integration tests. The API authenticates and validates the request, then returns the production response schema with sample data, so your assertions exercise the real contract while nothing is sent. ```ts // tests/messages.sandbox.spec.ts import { describe, expect, it } from 'vitest'; const BASE_URL = 'https://api.sent.dm'; describe('POST /v3/messages (sandbox)', () => { it('accepts a valid payload without sending anything', async () => { const response = await fetch(`${BASE_URL}/v3/messages`, { method: 'POST', headers: { 'x-api-key': process.env.SENT_API_KEY!, 'Content-Type': 'application/json', }, body: JSON.stringify({ sandbox: true, // validated, never sent to: ['+14155550123'], channel: ['sms'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', parameters: { name: 'Test' }, }, }), }); const body = await response.json(); expect(response.status).toBe(202); expect(response.headers.get('X-Sandbox')).toBe('true'); expect(body.success).toBe(true); expect(body.data.status).toBe('QUEUED'); expect(body.data.recipients[0].message_id).toBeDefined(); }); }); ``` ```python # tests/test_messages_sandbox.py import os import requests BASE_URL = "https://api.sent.dm" def test_send_accepts_valid_payload_without_sending(): response = requests.post( f"{BASE_URL}/v3/messages", headers={"x-api-key": os.environ["SENT_API_KEY"]}, json={ "sandbox": True, # validated, never sent "to": ["+14155550123"], "channel": ["sms"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "parameters": {"name": "Test"}, }, }, ) body = response.json() assert response.status_code == 202 assert response.headers["X-Sandbox"] == "true" assert body["success"] is True assert body["data"]["status"] == "QUEUED" assert body["data"]["recipients"][0]["message_id"] ``` The same pattern works for any endpoint in the [supported endpoints table](/reference/api/test-mode#supported-endpoints); sandbox delete requests, for example, return `204` without deleting anything. Sandbox responses contain sample data. A simulated `message_id` references no stored message, so do not follow it up with a `GET /v3/messages/{id}` assertion; that returns `404`. ## Run Sandbox Tests in CI Make the flag injectable so the same suite runs simulated in CI and live where you explicitly choose to. Read one environment variable in a shared helper: ```ts // tests/helpers/payload.ts export function withSandbox(payload: T) { // CI sets SENT_SANDBOX=true; a live run leaves it unset. return { ...payload, sandbox: process.env.SENT_SANDBOX === 'true' }; } ``` ```python # tests/helpers.py import os def with_sandbox(payload: dict) -> dict: # CI sets SENT_SANDBOX=true; a live run leaves it unset. return {**payload, "sandbox": os.environ.get("SENT_SANDBOX") == "true"} ``` Then set the variable in the pipeline: ```yaml # .github/workflows/test.yml - name: Integration tests env: SENT_API_KEY: ${{ secrets.SENT_API_KEY }} # The helper reads this and adds sandbox: true to every request, # so the run sends no messages and consumes no balance. SENT_SANDBOX: "true" run: npm run test:integration ``` ## Reproduce a Production Failure To debug a failed request without risking another live send, replay the exact production payload with the `sandbox` flag added: ```ts const failedPayload = { to: ['+14155550123'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' }, }; const response = await fetch('https://api.sent.dm/v3/messages', { method: 'POST', headers: { 'x-api-key': process.env.SENT_API_KEY!, 'Content-Type': 'application/json', }, // Same payload as production, plus the sandbox flag. body: JSON.stringify({ ...failedPayload, sandbox: true }), }); console.log(response.status, await response.json()); ``` ```python import os import requests failed_payload = { "to": ["+14155550123"], "template": {"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8"}, } response = requests.post( "https://api.sent.dm/v3/messages", headers={"x-api-key": os.environ["SENT_API_KEY"]}, # Same payload as production, plus the sandbox flag. json={**failed_payload, "sandbox": True}, ) print(response.status_code, response.json()) ``` Sandbox mode reproduces authentication and validation failures only. Failures that happen after a request is accepted, such as delivery errors, never occur in sandbox mode because execution is skipped. ## Keep Sandbox Mode Out of Production `sandbox` is a per-request body field, not an account setting or a key type, so the same running service can emit both simulated and live requests. If you toggle it by environment, make the production value an explicit `false`: ```ts // config.ts // Only production sends for real. export const sandbox = process.env.NODE_ENV !== 'production'; ``` ```python # config.py import os # Only production sends for real. SANDBOX = os.environ.get("ENVIRONMENT") != "production" ``` A `sandbox: true` that leaks into production silently drops real messages: the API returns `202` with `"status": "QUEUED"` as if the send were accepted, but nothing is delivered. Add a go-live check that asserts the toggle is off, and confirm production responses do not carry the `X-Sandbox` header. ## Verify the Result Every simulated response carries the `X-Sandbox: true` header, including error responses. Assert it in any test that must not send; if it is missing, the request executed for real. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | | No `X-Sandbox` header on the response | `sandbox` was not the JSON boolean `true`, or the endpoint does not support it | Send `"sandbox": true` in the request body and check the [supported endpoints table](/reference/api/test-mode#supported-endpoints). | | Tests fail with `401` | Missing or invalid API key | Sandbox requests still authenticate. Set `SENT_API_KEY` in the test environment. | | Follow-up reads return `404` | Sandbox responses contain generated sample IDs | Assert on the sandbox response itself, not on stored state. | ## Related ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/two-way-conversations.txt TITLE: Two-Way Conversations ================================================================================ URL: https://docs.sent.dm/llms/start/guides/two-way-conversations.txt How Sent handles two-way conversations, from inbound matching and storage to keyword detection, opt-out handling, auto-replies, and the WhatsApp 24-hour window. # Two-Way Conversations Sent's conversation window lets you hold a free-form, two-way conversation with a contact without managing per-channel session rules yourself. Sent owns the transport: the window state, cross-channel continuity, and compliance teardown. Your own agent (human or AI) owns the dialogue, integrating over webhooks and the freeform-send API. ## The 24-hour Window The 24-hour window applies to **WhatsApp only**. When you send a message and the contact replies, WhatsApp opens a 24-hour window from the moment their reply is received. While that window is open, you can respond with free-form text, no template required. If the contact does not reply within 24 hours, the window closes and you must use a template to send the next message. If the contact replies with any compliance keyword (see [Keyword Detection](#keyword-detection)), it does not trigger the two-way conversation window. No free-text reply is sent; the keyword is handled by the compliance engine only. The 24-hour countdown starts from the contact's last inbound message, not from when you sent the original outbound. Each new inbound from the contact resets the window. SMS and RCS have no time window. As long as you have received an inbound message from the contact, you can reply with free text. --- ## How Inbound Messages Work ### 1. Account Resolution Sent matches an inbound message to your account using the **receiving number on that channel**: - **SMS**: matched via the phone number provisioned to your account - **WhatsApp**: matched via your WhatsApp number's `PhoneNumberId` If the receiving number isn't provisioned to any account on that channel, the inbound message is dropped. No account match, no storage. If a message arrives from a contact you have never interacted with or don't have registered, Sent automatically creates the contact record before responding. If the contact already exists, Sent replies directly without any additional steps. ### 2. Every Inbound Is Stored Once the number resolves to your account, all inbound messages are stored as `RECEIVED` in the Sent backend, including keyword messages like `STOP` or `HELP`. Only non-keyword replies are surfaced in the message log and visible in the dashboard. If you have no provider configured for the inbound channel, that channel's conversation handling does not run and nothing is stored. ### 3. Reply From Same Sender Routing When an inbound arrives, Sent records the channel, provider, and sender number it came in on. Future outbound messages to that contact will prefer the same sender, so your reply comes from the same number the contact originally wrote to. --- ## Keyword Detection Sent enforces CTIA/TCPA rules for keyword handling. The rules are strict by design. Keywords fall into three groups: opt-out keywords such as `STOP` and `CANCEL`, opt-in keywords such as `START`, and help keywords such as `HELP`. The full default list and matching rules live in the [Two-Way Messaging Reference](/reference/two-way-messaging#default-keywords). **Exact match only.** The entire trimmed message body must equal the keyword, and case is ignored: `stop`, `Stop`, and `STOP` all match. `"Please stop messaging me"` does not. ### Custom Opt-Out Keywords You can extend the defaults with words that fit your brand or audience. For example, instead of `STOP` you could configure `NDALO` or any word your contacts are more likely to use. Custom keywords are configured in the dashboard under **Compliance → Opt Keywords**. The same exact-match rule applies: the entire trimmed message body must equal the configured keyword. Default keywords are seeded for every account and remain active alongside any custom keywords you add. ### RCS Suggestion Chips Sent appends a STOP suggestion chip to every outbound RCS message, so recipients can opt out with a single tap. Chip taps arrive as `message.received` events with the chip's reply text in `payload.text`. There is no separate event type, so handle them in the same webhook handler as typed replies. Taps on the built-in STOP chip carry opt-out postback data and are processed by the consent engine directly: the contact's `opt_out` flag is flipped without relying on keyword matching. Typed RCS replies go through standard keyword detection. ### STOP: Always Honored When a contact sends `STOP` (or any opt-out keyword), opt-out is recorded unconditionally. Sent does not check whether the receiving number is currently active or assigned. Consent suppression always wins. **Timing:** The auto-reply confirmation is sent **before** the consent write. This is intentional: TCPA permits one final message to confirm opt-out, so the reply goes out first, then the opt-out flag is set. ### START: Always Honored When a contact sends `START` (or any opt-in keyword), opt-in is recorded unconditionally. If the sending number is not yet a contact, Sent creates the contact record first, then writes the opt-in consent, then sends the auto-reply. ### Cross-Channel Opt-Out Opt-out is **contact-level and channel-agnostic**. A `STOP` received on any channel (SMS, RCS, or WhatsApp) suppresses the contact across all channels. Consent lives in one place: the `opt_out` flag on the contact record in your account. All outbound messages, including auto-replies, check this flag before sending, regardless of which channel they use. If you mirror consent into your own systems, see [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent). --- ## Auto-Replies Auto-replies are not special-cased messages. They go through the **same send pipeline** as any outbound message. That means they require: - An approved template configured for the action (STOP reply, START reply, HELP reply) - Consent to be valid (checked after the opt-out write for START; before for STOP) - A matching message route with a valid E.164 sender number **WhatsApp delivery format:** On WhatsApp, keyword auto-replies are delivered as free-form session text inside the 24-hour conversation window that the contact's keyword message just opened; opt templates are not registered with Meta. The approval requirement is unchanged: the configured STOP/START/HELP template must still be approved, or the reply is silently skipped, exactly as on SMS and RCS. **Template must be approved (all channels).** Auto-replies using a template in `DRAFT` or `PAUSED` status will fail silently: the keyword is still processed, but no reply is sent. Make sure your STOP, START, and HELP templates are published. ### Routing Auto-Replies Auto-replies are routed through the standard routing engine: the provider and sender number are resolved by the same routing rules as any outbound message (see [Channel Routing](/reference/channel-routing)). Phone numbers must be in **canonical E.164 format** (for example, `+14155551234`) on both the stored route and the contact record for a match to succeed. --- ## Channel Scope Two-way conversation features are not uniform across channels. The [feature matrix and provider support tables](/reference/two-way-messaging#channel-support) enumerate exactly what each channel supports; the short version: - **WhatsApp** runs the same keyword detection, opt-out, and auto-reply pipeline as SMS and RCS. Auto-replies are delivered as free-form text inside the 24-hour conversation window, and the configured auto-reply template must still be approved for the reply to send. Meta also enforces its own opt-out natively (in-app block and report, quality rating); this is in addition to Sent's pipeline, not instead of it. - **RCS** supports the full two-way pipeline, and every outbound RCS message carries a built-in STOP chip (see [RCS Suggestion Chips](#rcs-suggestion-chips)). - **SMS** two-way messaging depends on the provider and number type. Only long codes and short codes on MO-capable providers can receive inbound messages; alphanumeric sender IDs and SMPP providers are send-only. If you are using an alphanumeric sender ID or an SMPP provider and contacts send keywords (STOP, START, HELP), those messages never reach Sent. --- ## Common Failure Scenarios These are the most frequent reasons two-way conversation handling silently stops working. ### SMS channel removed or unprovisioned If your SMS channel is removed or the provisioned number is no longer assigned, inbound messages on that number cannot be matched to your account. The opt-out (or any other keyword) is dropped. It is never processed. **Channel resolution must succeed before anything else runs.** If contacts are texting in and nothing is happening, verify that the SMS provider and number are still active and assigned under your channel settings. ### SMS provider or number type does not support inbound Two-way messaging on SMS requires both an MO-capable provider and a supported number type. **MO (Mobile Originated)** refers to a message that originates from a contact's phone and travels toward your app. An MO path is the provider's ability to receive those inbound messages and forward them to Sent. Providers that only support **MT (Mobile Terminated)** can deliver messages *to* a phone but cannot receive replies *from* one, making two-way messaging impossible on those routes. Two common silent failure points: - **SMPP providers**: support delivery receipts only. No inbound message is received, stored, or keyword-processed on these routes. - **Alphanumeric sender IDs**: send-only by design. Contacts cannot reply to an alphanumeric sender; any attempt is dropped at the carrier level before it reaches Sent. If contacts are sending keywords and nothing is happening, verify that your route uses a long code or short code on an MO-capable provider. The [SMS provider support table](/reference/two-way-messaging#sms-provider-support) lists which providers have an inbound path. ### Free-form opt-out phrases are not recognized Messages like `"Cancel my subscription"`, `"Please remove me"`, or `"I don't want these anymore"` do not trigger opt-out. Only an exact keyword match does. Contacts who send free-form messages will continue to receive outbound messages until they send an exact keyword (`STOP`, `CANCEL`, or a configured custom keyword). If your contacts are likely to use natural language, consider adding a custom keyword that matches a common phrase, but it must still be a single, exact token, not a sentence. ### Auto-reply template not approved If the template configured for a STOP, START, or HELP auto-reply is in `DRAFT` or `PAUSED` status, the keyword is still processed (the opt-out is written) but no confirmation reply is sent. Contacts will not receive any acknowledgement. Resolve this by publishing the template in the dashboard. See [Working with Templates](/start/guides/working-with-templates) for approval steps. --- ## Receiving Inbound Messages via Webhook Subscribe to `message.received` events to be notified in real time when a contact replies. The webhook fires for every inbound message, keywords included, and carries the message text, channel, and sender number. Use `message_id` as your idempotency key, because the same event can be delivered more than once. [Receiving Inbound Messages](/start/webhooks/receiving-inbound-messages) walks through a complete handler: acknowledging deliveries, deduplicating, storing the message, and skipping compliance keywords. Payload fields are documented in the [Events Reference](/start/webhooks/event-types). --- ## Retrieving Conversations Sent groups the messages between you and a single contact into a conversation: one continuous thread across every channel. The conversation id is deterministic, computed from your account's customer id and the contact id, so the same pair always produces the same id no matter which sender number or channel carried the messages. The reason for this design is continuity: a contact who replies over SMS today and WhatsApp tomorrow is still one conversation. Two endpoints expose conversation history, `GET /v3/conversations` and `GET /v3/conversations/{id}`. Parameters, response shapes, and the id derivation are documented in the [conversation history endpoints reference](/reference/two-way-messaging#conversation-history-endpoints). --- ## Where to Go Next Sent owns the transport of a two-way conversation: inbound matching and storage, keyword compliance, auto-replies, and cross-channel continuity. You own the dialogue, through webhooks and the send API. - [Receiving Inbound Messages](/start/webhooks/receiving-inbound-messages): build the webhook handler for contact replies - [Two-Way Messaging Reference](/reference/two-way-messaging): keywords, channel support, and conversation endpoints - [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent): mirror consent state into your own systems - [Working with Templates](/start/guides/working-with-templates): publish the templates that auto-replies depend on ================================================================================ SOURCE: https://docs.sent.dm/llms/start/guides/working-with-templates.txt TITLE: Working with Templates ================================================================================ URL: https://docs.sent.dm/llms/start/guides/working-with-templates.txt Start here for Sent message templates: build one in the dashboard, look up the definition JSON in the reference, and learn how templates and approval work. # Working with Templates A **template** is a reusable message blueprint: structured content with dynamic variables, links, media, and interactive buttons that adapts automatically to SMS, WhatsApp, and RCS. You build a template once, get it approved, and then send personalized messages at scale by referencing its template ID. **Start sending right away.** Sent includes 6 pre-built OTP and verification templates available immediately after email and phone verification, no custom template creation or approval needed. Custom templates require a [completed account setup](/start/quickstart/account-setup). ## Template documentation Build a template in the visual builder: header, body, dynamic variables, footer, buttons, and submitting for review. Every field, limit, and status of the template definition JSON used by the API and by the builder's View JSON action. Why templates are channel-agnostic, how content adapts per channel, and how the template lifecycle is managed. A guided quickstart for creating and submitting your first template. ## How template approval works Every custom template starts as a draft, and drafts cannot be sent on any channel. Submitting a template for review starts approval: - With a connected WhatsApp Business Account, **Meta** reviews the template. A Meta approval applies to every channel; a Meta rejection blocks only WhatsApp. - Without a connected WhatsApp Business Account, **Sent's compliance team** reviews the template, and each channel is approved or rejected individually. Approval is tracked per channel: a message sends on a channel only after the template is approved for that channel, and sends against unapproved templates are blocked. The pre-built OTP and verification templates are already approved on all channels. Refer to [template statuses](/reference/api/template-definition#template-statuses) for the full status list and review routing details. ## Template best practices Following these guidelines helps you avoid common issues with template creation and approval. **Keep templates concise and transactional.** Templates that clearly serve a transactional or utility purpose (order updates, appointment reminders, verification codes) are approved faster and categorized correctly more often than templates with ambiguous intent. **Avoid marketing language in utility templates.** Even a single promotional phrase can cause Meta to re-categorize your utility template as marketing. Stick to factual, action-oriented language that directly relates to the triggering event. **Test with a simple text-only template first.** Before building a template with media, buttons, and multiple variables, first create and send a basic text-only template. This confirms your WhatsApp Business Account connection, permissions, and API integration are all working correctly. Add complexity from there. **Use meaningful template names.** Use descriptive, consistent names like `order_confirmation`, `shipping_update`, or `otp_verification` rather than generic names like `template1` or `test`. Send requests can reference a template by name, and renaming later means updating every place that name appears in your code. **Plan for approval time.** Build template review time into your development workflow. Submit templates for approval well before you need them in production. Having a library of pre-approved templates ready to go prevents delays when launching new features. If a template is failing to save, stuck in review, or miscategorized, see [WhatsApp Template Issues](/troubleshooting/template-issues) for symptom-by-symptom fixes. ## Manage templates via the API | Action | Endpoint | | :--- | :--- | | [Create a template](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint) | `POST /v3/templates` | | [List templates](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint) | `GET /v3/templates` | | [Get a template](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint) | `GET /v3/templates/{id}` | | [Update a template](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint) | `PUT /v3/templates/{id}` | | [Delete a template](/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint) | `DELETE /v3/templates/{id}` | Template status changes are delivered to your webhook endpoint as `templates` events; see [webhook event types](/start/webhooks/event-types). ================================================================================ SOURCE: https://docs.sent.dm/llms/start.txt TITLE: One API for SMS, WhatsApp, and RCS ================================================================================ URL: https://docs.sent.dm/llms/start.txt Build multi-channel messaging with the Sent unified API: send SMS, WhatsApp, and RCS through one interface with automatic routing, fallback, and cost control. # One API for SMS, WhatsApp, and RCS Build intelligent multi-channel messaging into your applications with the Sent unified API. Send SMS, WhatsApp, and RCS messages through a single interface with automatic routing, fallback, and cost optimization. **Try before you commit.** All you need is a verified email and phone number: get API access, 6 pre-built OTP templates, and 500 sends/day in ~2 minutes. [Start now →](/start/try-sent) ## New to Sent? Follow this structured learning path from understanding the platform to production deployment: } /> } /> } /> ## Implementation Guides Practical guides for building with Sent: } /> } /> } /> } /> } /> } /> ## Webhooks & Events Receive real-time notifications about your messages: } /> } /> } /> } /> ## Advanced Topics Deep dives for power users and enterprises: } /> } /> } /> } /> ## Help & Reference } /> } /> } /> } /> For the v3 authentication scheme, sandbox mode, and the shape of every request and response, see the [API Reference](/reference/api); for webhook payloads, see the [webhook event types reference](/start/webhooks/event-types). --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/llm-docs.txt TITLE: Docs for LLMs & AI Agents ================================================================================ URL: https://docs.sent.dm/llms/start/llm-docs.txt Machine-readable versions of the Sent documentation, plain-text files optimized for LLMs, AI coding assistants, and autonomous agents. # Docs for LLMs & AI Agents All Sent documentation is also available in plain-text format, following the [llms.txt standard](https://llmstxt.org/). These files are optimized for LLMs, AI coding assistants (Cursor, Copilot, Windsurf), and autonomous agents that need structured, crawlable content without HTML noise. ## Available Files | File | URL | Best for | |------|-----|----------| | **Index** (`llms.txt`) | [docs.sent.dm/llms.txt](https://docs.sent.dm/llms.txt) | Agents that discover and selectively fetch only relevant articles | | **Full docs** (`llms-full.txt`) | [docs.sent.dm/llms-full.txt](https://docs.sent.dm/llms-full.txt) | One-shot context loading: every article in a single file | ### Per-Article Files Every documentation page has its own plain-text file at a predictable URL: ``` https://docs.sent.dm/llms/
/
.txt ``` Examples: | Article | Plain-text URL | |---------|---------------| | Sending Messages guide | [`/llms/start/guides/sending-messages.txt`](https://docs.sent.dm/llms/start/guides/sending-messages.txt) | | Send Message endpoint | [`/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint.txt`](https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint.txt) | | Channels concept | [`/llms/start/concepts/channels.txt`](https://docs.sent.dm/llms/start/concepts/channels.txt) | The full list of per-article URLs is in [`llms.txt`](https://docs.sent.dm/llms.txt). ## How to Use ### With Cursor / Windsurf / Copilot Add the index URL as a documentation source in your editor's AI settings: ``` https://docs.sent.dm/llms.txt ``` The tool will fetch the index and selectively load the relevant articles into context as you work. ### With Claude (claude.ai or API) Paste the full documentation directly into your conversation or system prompt: ``` https://docs.sent.dm/llms-full.txt ``` Or point an agent tool at the index to let it fetch only what it needs: ``` https://docs.sent.dm/llms.txt ``` ### With Autonomous Agents Agents can discover available content by fetching `llms.txt` first, then requesting individual `.txt` files. Every per-article file begins with a `URL:` line so the agent always has the canonical source, even when files are cached or passed out of context. ``` URL: https://docs.sent.dm/llms/start/guides/sending-messages.txt # Sending Messages ... ``` These files are regenerated automatically on every commit that touches documentation source files, so they are always in sync with the live docs. ## Autodiscovery The `` tag is present in the HTML `` of every page on this site, allowing AI crawlers and browser-level agents to discover the index without prior knowledge of the URL: ```html ``` --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/llm-docs/mcp-server.txt TITLE: Sent MCP Server ================================================================================ URL: https://docs.sent.dm/llms/start/llm-docs/mcp-server.txt Use Sent directly from Claude Code, Cursor, VS Code, and other AI coding agents via the Model Context Protocol # Sent MCP Server The Sent MCP server exposes every messaging primitive (send, lookup, contacts, templates, and analytics) directly inside your AI coding agent. No API keys to paste, no context-switching to another tab. Your agent can send a real SMS, look up a contact, or check deliverability without leaving your editor. The MCP server is available at **[mcp.sent.dm](https://mcp.sent.dm)**. It implements the [Model Context Protocol](https://modelcontextprotocol.io/) over HTTP with OAuth 2.1 authentication. ## What you can do | Category | Available tools | |---|---| | **Messaging** | Send SMS, WhatsApp, and RCS · Get message status · List message activity | | **Contacts** | List, get, create, delete contacts · Message summary per contact | | **Templates** | List, get, and delete templates · Look up by name | | **Lookup & Analytics** | Phone number lookup · Messages sent · Deliverability · Contact stats | | **Account** | Get account info · Check balance · Onboarding status | 19 tools in total, mirroring the full Sent REST API. --- ## Setup ### Add the MCP server to your agent Pick your agent below and follow the one-time setup. Run this command in your terminal: ```bash claude mcp add --transport http sent https://mcp.sent.dm/mcp ``` That's it. Claude Code registers the server and handles authentication on first use. Open or create `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) and add the `sent` entry: ```json { "mcpServers": { "sent": { "type": "http", "url": "https://mcp.sent.dm/mcp" } } } ``` On **Windows** the config file is at `%APPDATA%\Claude\claude_desktop_config.json`. Restart Claude Desktop after saving. Open or create `~/.cursor/mcp.json` and add: ```json { "mcpServers": { "sent": { "url": "https://mcp.sent.dm/mcp" } } } ``` Restart Cursor after saving. The server will appear under **Settings → MCP**. Add a `.vscode/mcp.json` file at the root of your project (or your user settings): ```json { "servers": { "sent": { "type": "http", "url": "https://mcp.sent.dm/mcp" } } } ``` VS Code Copilot will pick up the server automatically on next activation. ### Authenticate The first time your agent calls a Sent tool, it will trigger an OAuth 2.1 authorization flow: 1. Your agent opens a browser tab to `app.sent.dm/mcp-authorization` 2. You log in with your Sent account and select the organization and sender profile to grant access to 3. An access token is issued and stored by the agent, no copy-pasting required The token has no expiry. To revoke access, go to **Dashboard → Settings → MCP Connections** and deactivate it from there. ### Use it Ask your agent anything that involves messaging: ``` Send a WhatsApp message to +1234567890 using the order_confirmation template with customerName="Alex" and orderNumber="#9981" ``` ``` Look up the phone number +447911123456 and tell me what channel it supports ``` ``` Show me deliverability stats for the last 7 days ``` The agent selects the right tool, constructs the call, and shows you the result inline. --- ## Available tools reference ### Messaging | Tool | Description | |---|---| | `messages.send` | Send a message via SMS, WhatsApp, or RCS | | `messages.get` | Retrieve a single message by ID | | `messages.activities.list` | Get the full lifecycle timeline for a message | ### Contacts | Tool | Description | |---|---| | `contacts.list` | List contacts in your account | | `contacts.get` | Get a single contact by ID | | `contacts.create_many` | Bulk-create contacts | | `contacts.delete` | Delete a contact | | `contacts.message_summary` | Get messaging history summary for a contact | ### Templates | Tool | Description | |---|---| | `templates.list` | List all templates | | `templates.get` | Get a template by ID | | `templates.get_by_name` | Get a template by name | | `templates.delete` | Delete a template | ### Lookup & Analytics | Tool | Description | |---|---| | `numbers.lookup` | Look up a phone number (channel support, validity) | | `dashboard.messages_sent` | Messages sent over a time range | | `dashboard.deliverability` | Delivery rate stats | | `dashboard.contacts` | Contact growth and activity stats | ### Account | Tool | Description | |---|---| | `account.get` | Get account details | | `balance.get` | Check current balance | | `onboarding.status` | Check onboarding and KYC status | --- ## Authentication details The Sent MCP server uses **OAuth 2.1 with PKCE** (RFC 7636) and **Dynamic Client Registration** (RFC 7591). Your agent registers itself automatically. No manual client ID setup required. - **Token expiry:** None. Tokens are long-lived. - **Revocation:** Dashboard → Settings → MCP Connections → Deactivate. - **Scope:** Tied to a specific organization and Sender Profile chosen during authorization. To grant access to a different profile, re-authorize. --- ## Related --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/quickstart/account-setup.txt TITLE: Account Setup ================================================================================ URL: https://docs.sent.dm/llms/start/quickstart/account-setup.txt Create your Sent account and complete KYC verification: business details, destination countries, messaging use case, and billing to unlock the full platform. # Account Setup Complete your account setup and KYC verification to unlock full access to the Sent platform. **What you'll accomplish**: Create account → Verify identity → Complete KYC → Set up billing **⏳ Time required**: ~10 minutes **Not ready to commit yet?** You can [start with light onboarding](/start/try-sent): just a verified email and phone number gets you API access, 6 pre-built templates, and 500 sends/day. Come back here when you're ready for custom templates or your own sender number. ## Prerequisites - Business email address - Business registration details - Government-issued ID or business documents (for some countries) - Payment method (credit/debit card) ## KYC Process Overview ## Create Your Account 1. Visit [app.sent.dm/sign-up](https://app.sent.dm/sign-up) 2. Enter your business email address 3. Check your email for the magic verification link 4. Click the link to verify your account The verification link expires after 1 hour. If it expires, you can request a new one on the login page. ## Verify Your Phone Number After clicking the magic link, you will be prompted to verify your phone number. A verification code will be sent via WhatsApp, or SMS if WhatsApp is not available for your number. 1. Complete the **bot challenge** on the verification page 2. Enter your phone number to receive a verification code via WhatsApp or SMS 3. Enter the code to confirm your number and continue If you have WhatsApp installed on the provided number, the code will be delivered there. Otherwise, it will arrive via SMS. ## Start KYC Verification From your [Sent Dashboard](https://app.sent.dm/dashboard), click the **"Start Verification"** button to begin KYC. KYC (Know Your Customer) verification is required to: - Comply with messaging regulations - Prevent fraud and abuse - Unlock full platform access ## Business Contact Details Fill in your business contact information: Required information: - Business name - Contact email - Business phone number - Business address ## Business Details Provide your business registration information: Required information: - Business registration number - Legal business name - Business type/structure - Industry category ## Select Destination Countries Choose the countries you plan to send messages to: - **United States** - For US-only messaging - **Global** - For worldwide messaging - **Selected Countries** - Choose specific countries Some countries require additional compliance documents. The table below lists known examples; the verification flow shows the exact document requirements for each country you select. ### Country-Specific Requirements | Country | Required Documents | |---------|-------------------| | Australia (AU) | Utility Bill | | Belgium (BE) | Proof of Local Address, Passport, Business Registration Certificate | | Poland (PL) | Proof of Local Address | | South Africa (ZA) | Proof of Local Address | | Sweden (SE) | Proof of Local Address | | Thailand (TH) | Proof of Worldwide Address, Business Registration Certificate | | United Kingdom (UK) | Proof of Local Address | ## Messaging Use Case Select your messaging use case and campaign details: Common use cases: - **Authentication** - 2FA, OTP, password resets - **Notifications** - Order updates, shipping alerts - **Marketing** - Promotional campaigns - **Customer Service** - Support messages - **High Volume** - Multiple use cases Use the **"Suggest"** button to automatically fill campaign details based on your use case. ## Set Up Billing Add your payment method: Payment options: - Credit/debit card (Visa, Mastercard, American Express) - Pre-load account credits (optional) You can use the **"Use Business Address"** button to copy your business address to billing. ## Submit Your Application Review all details and submit your KYC application: **Timeline:** Usually approved within a few hours, sometimes minutes. You'll receive an email confirmation. While verification is processing, you can continue with channel setup to save time. ## What's Next? Once your KYC is approved: 1. **[Set up your channels](/start/quickstart/channel-setup)** - Configure SMS, WhatsApp, and RCS 2. **[Create your first template](/start/quickstart/first-template)** - Prepare message content 3. **[Send your first message](/start/quickstart/first-message)** - Make your first API call ## Troubleshooting **KYC rejected?** Common reasons: - Incomplete business information - Mismatched business registration details - Missing required documents for selected countries Contact [support@sent.dm](mailto:support@sent.dm) with your KYC submission ID for assistance. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/quickstart/channel-setup.txt TITLE: Channel Setup ================================================================================ URL: https://docs.sent.dm/llms/start/quickstart/channel-setup.txt Configure your own SMS, WhatsApp, and RCS sender for production: select a phone number, connect WhatsApp Business, enable RCS, and set up billing in Sent. # Channel Setup Configure your own SMS, WhatsApp, and RCS sender for production use. **What you'll accomplish**: Select phone number → Connect WhatsApp → Enable RCS → Configure billing **⏳ Time required**: ~5 minutes **Just exploring?** You don't need this step to start sending. [Light onboarding](/start/try-sent) gives you API access and 6 pre-built templates with just a verified email and phone number. Come back here when you're ready to send from your own number. ## Prerequisites - [Completed KYC verification](/start/quickstart/account-setup) - [Meta Business Portfolio](https://business.facebook.com) account (for WhatsApp) - Payment method on file ## Start Channel Setup From your [Sent Dashboard](https://app.sent.dm/dashboard), click the **"Continue Channel Setup"** button. ## Select Your Phone Number Choose a phone number that will be your business identity across SMS, WhatsApp, and RCS: **Important considerations:** - Recipients will see this number on all communications - Choose a number local to your target market - This number cannot be changed easily later - We recommend using the same number for SMS, WhatsApp, and RCS ## Connect WhatsApp Business Link your WhatsApp Business account: **Steps:** 1. Log in with your Facebook/Meta account 2. Select or create a [WhatsApp Business account](https://developers.facebook.com/start/whatsapp/overview/business-accounts) 3. Grant Sent permission to manage messages 4. Add payment details to your Meta Business account Don't have a Meta Business Portfolio? You can create one during this process. We strongly recommend using the **same number** for SMS, WhatsApp, and RCS to ensure smooth operation. ## Enable RCS RCS (Rich Communication Services) delivers carrier-native rich messages (suggestion chips, rich cards, carousels, and branded sender identity) to Android (Google Messages) users without requiring a separate app install. **Key points:** - RCS is delivered over carrier networks to Android (Google Messages) users - Messages display your company logo, name, and a verified checkmark instead of a phone number - Rich features (images, carousels, branded sender identity, suggestion chips) require an approved RCS sender profile - Falls back to SMS automatically for devices or carriers without RCS support **RCS setup is not self-service.** Unlike SMS and WhatsApp, RCS requires a one-time approval process with carriers before you can send. Contact [Sent](mailto:support@sent.dm) to initiate your RCS onboarding. Once approved, your RCS Agent (branded sender) will appear in the dashboard. RCS availability depends on carrier support in the recipient's region. Sent automatically handles fallback to SMS when RCS is unavailable. Use `"channel": ["rcs", "sms"]` in your API requests to enable this pattern explicitly. ## Add Sent Billing Details and a Payment Method Enter your billing address for your Sent account: Then add a payment method (credit or debit card): The payment method is optional at this stage (you can add one later in the [Billing section](https://app.sent.dm/dashboard/billing)), but messages are blocked when your credit balance runs out, so we recommend adding it now. This step configures billing for your **Sent account** (message credits). WhatsApp per-message charges are billed separately by **Meta**, through the payment method you added to your Meta Business account when connecting WhatsApp. ## Complete Setup Wait for the channel setup to complete (usually takes a few seconds): 🎉 **Congratulations!** Your account is now ready to send messages. **Copy your API credentials** from this page, or access them later in the [API Keys](https://app.sent.dm/dashboard/api-keys) section. **Security reminder:** NEVER share your Sender ID and API Key(s) with anyone. Store them securely in environment variables. ## API Credentials You'll receive two credentials: ```http x-sender-id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` For the current v3 API, you only need the `x-api-key` header. The `x-sender-id` is for legacy v2 API. ## What's Next? Now that your channels are configured: 1. **[Create your first template](/start/quickstart/first-template)** - Prepare message content 2. **[Send your first message](/start/quickstart/first-message)** - Make your first API call 3. **[Explore the dashboard](/start/quickstart/dashboard-walkthrough)** - Learn dashboard features ## Troubleshooting **WhatsApp connection failed?** - Ensure your Meta Business account is verified - Check that your phone number is not already registered with WhatsApp - Verify you have administrator access to the Meta Business Portfolio **SMS not working?** - Verify your KYC is fully approved - Check that your payment method is valid - Contact [support@sent.dm](mailto:support@sent.dm) --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/quickstart/dashboard-walkthrough.txt TITLE: The Sent Dashboard ================================================================================ URL: https://docs.sent.dm/llms/start/quickstart/dashboard-walkthrough.txt Tour the Sent Dashboard: send messages from the playground, manage contacts and templates, generate API keys, and monitor deliverability and account balance. # The Sent Dashboard The Sent Dashboard is your central hub for sending messages, managing contacts, templates, generating API keys, and managing your account. - You will need a [Sent](https://app.sent.dm) account to be able to access the Sent Dashboard - If you don't have one, you can [create a Sent account](https://app.sent.dm/sign-up) - You will receive an email with a magic verification link to verify your account ## Getting Started The dashboard is available at two levels depending on your onboarding stage: - **Light onboarding** (email + phone verified): Access templates, API keys, playground, contacts, activities, and number lookup. The 6 pre-built templates are ready to use immediately. - **Full setup** (KYC + channels complete): Unlocks all features including your own sender number, custom templates, compliance management, and billing. If you haven't started yet, follow the [Quickstart](/start/quickstart): you can have API access in ~2 minutes with just email and phone verification. ### Dashboard features The Sent Dashboard is a web-based platform that allows you to: - Have an overview of your account, messaging statistics, deliverability and balance - Manage your contacts and templates - Perform number lookup and validation - For developers - A dynamic playground to send messages directly from the dashboard - Generate and manage API keys and credentials - View your messaging activity and logs, including webhook interactions - Manage Sender Profiles (your outbound identities for SMS and WhatsApp) - Configure messaging channels and destination countries - Invite and manage organization users with role-based access - Manage account settings and billing ### Sidebar layout The Sent Dashboard sidebar is organized into five sections that group related features: - **Main**: Overview, Templates, Playground, Contacts, Profiles - **Tools**: Number Lookup - **Development**: Activities, API Keys, Webhooks, Documentation - **Account**: Compliance, Channels, Users, Settings, Billing - **Help**: email support, Help Center At the top of the sidebar you'll also find the **Context switcher**, which lets you switch between organizations you belong to. New sidebar entries (such as **Profiles** and **Users**) are marked with a New badge. ## Dashboard Comparison (Before and after full setup) Drag the slider to compare the dashboard before and after completing KYC and channel setup. Note: with light onboarding (email + phone only), you already have access to templates, API keys, and the playground. The slider shows the additional features unlocked by full setup. ## The Dashboard in detail ### Overview Page The [Overview](https://app.sent.dm/dashboard) page is the first page you will see when you log in to your Sent Dashboard. It gives you a quick overview of your account with the following sections: - **Message Templates**: shortcut to create a new template or view all existing ones - **Total Contacts**: total number of contacts in your account - **Deliverability**: your overall message deliverability rate - **Balance**: your current credit balance with a link to manage billing - **Messages**: a bar chart showing SMS and WhatsApp messages sent in the last 24 hours - **Contacts**: a preview of your most recently messaged or added contacts ### Templates Page The [Templates](https://app.sent.dm/dashboard/templates) page is where you can create and manage your message templates. At the top of the page you'll see four creation cards covering every way to build a template: - **Create from Scratch**: start with a blank template - **Import from Meta**: import an approved template from your WhatsApp Business Account (WABA) - **Create From Definition**: build a template from a JSON definition - **Create from Sample**: choose from pre-designed templates Below the creation cards, the **Your Templates** section lists every template in your account. You can: - Search templates by name, category, or description - Filter by status (Draft, Approved, Pending, Rejected) and category (Marketing, Utility) - View per-template analytics - Edit and delete existing templates Template creation requires full onboarding. With light onboarding you can still use the 6 pre-built sample templates. Once you create a custom template, it is automatically submitted to Meta for WhatsApp approval. You can still send SMS messages while the template is being reviewed. **Want to learn more about templates?** Check out the [Templates](/start/guides/working-with-templates) guide. ### Playground Page The [Playground](https://app.sent.dm/dashboard/playground) page lets you send messages directly from the dashboard without writing any code. To send a test message: 1. **Select a template** from the dropdown 2. **Pick a recipient**: either enter a phone number directly or choose an existing contact from your saved list 3. Click **Send Message** The **Activity** pane on the right tracks delivery in real time so you can confirm the message status (Sent, Delivered, Failed) without leaving the page. ### Contacts Page The [Contacts](https://app.sent.dm/dashboard/contacts) page is where you can manage your contacts. In this page, you can: - Create new contacts individually or in bulk via the **Create Contacts** button - Search contacts and use the **Filter** menu to narrow results by channel or country - View each contact's ID, country, phone number, default channel, and all available channels - Take per-row actions (edit, delete, copy the contact ID) Each row shows: **CONTACT ID**, **COUNTRY**, **PHONE NUMBER**, **DEFAULT CHANNEL**, **AVAILABLE CHANNELS**, and **ACTIONS**. **Want to learn more about contacts?** Check out the [Contacts](/start/guides/managing-contacts) guide. ### Profiles Page The [Profiles](https://app.sent.dm/dashboard/sender-profiles) page is where you manage your **Sender Profiles**, the outbound identities used when sending SMS and WhatsApp messages. Each Sender Profile card shows: - A **display name** and **brand description** - The profile's unique ID, labeled **`x-sender-id`** (with a one-click copy icon). Organization API keys pass this UUID in the `x-profile-id` header to scope v3 API requests to that profile - **SMS** and **WhatsApp** channel configuration and status (for example, _Not configured_, _Active_, _Failed_) - A **More options** menu to edit or remove the profile Click **Create Sender Profile** to add a new one. You can create multiple Sender Profiles to represent different brands, use cases, or regions within the same account. ### Number Lookup Page The [Number Lookup](https://app.sent.dm/dashboard/phone-number-lookup) page is where you can look up and validate any phone number. Select the country dialing code, enter a phone number, and click **Lookup** to get detailed information including its format, region, type, validity, and timezone data. Use **Clear** to reset the form and run another lookup. ### Activities Page The [Activities](https://app.sent.dm/dashboard/activities) page is where you can view your messaging logs. Every message sent through Sent, via API or the Playground, appears here with the following details: - **Message ID**: unique identifier for each message (sortable) - **Contact Phone**: recipient phone number (sortable) - **Channel**: SMS or WhatsApp - **Template**: the template used - **Direction**: Outbound or Inbound - **Status**: Sent, Delivered, or Failed - **Initiated At**: timestamp of when the message was sent (sortable) Clicking a message row opens the message detail panel. The cost section shows a single **Price** field, the total amount charged for that message, alongside the pricing model applied. Use the **Search** bar to find a specific message and the **Filter** menu to narrow results by channel, direction, or status. ### API Keys Page The [API Keys](https://app.sent.dm/dashboard/api-keys) page is where you can generate and manage your API keys and credentials. The page displays your organization's **`x-sender-id`** at the top. Current v3 API requests authenticate with the `x-api-key` header alone; the `x-sender-id` header is required only by the legacy v2 API. Below that, click **Add API Key** to create a new key, and use the table to manage existing keys (each row shows the key's name, masked value, status, creation date, and actions to enable, edit, or delete). **Security reminder:** NEVER share your Sender ID and API Key(s) with anyone. **Quick tip:** Use the copy icon next to your `x-sender-id` and each `x-api-key` to copy them without errors. ### Webhooks Page The [Webhooks](https://app.sent.dm/dashboard/webhooks) page is where you can manage your webhooks. Webhooks are used to receive real-time updates and events about the status of your messages. Click **Add Webhook** to register a new endpoint. The table lists every webhook with its display name, endpoint URL, status (Active/Inactive), and creation date. Use the **Search** bar and **Filter** menu to quickly find a specific webhook. **Want to learn more about webhooks?** Check out the [Webhooks](/start/webhooks/getting-started) guide. #### Webhook Details Clicking a webhook row opens its detail page. The header shows the display name and endpoint URL, with a summary row underneath: - **Created**: when the endpoint was registered - **Status**: Active or Inactive - **Listening for**: how many event types the endpoint subscribes to. Hover the info icon to list them, including any per-event filters - **Signing secret**: masked by default. Use the eye button to reveal it and the copy button to copy it, then [verify request signatures](/start/webhooks/signature-verification) in your handler The actions menu in the top right holds the endpoint operations: | Action | Effect | |--------|--------| | **Edit endpoint** | Change the display name, URL, or subscribed event types | | **Disable** or **Enable endpoint** | Stop or resume deliveries without deleting the endpoint or losing its secret | | **Rotate signing secret** | Issue a new signing secret. The old secret is invalidated immediately, with no overlap window, so store the new one in your handler as part of the same change | | **Test Webhook** | Send a sample payload for any subscribed event to your endpoint. See [local development](/start/webhooks/local-development) | | **Delete endpoint** | Remove the endpoint permanently, along with its secret and delivery history | Below the summary, the **Events** table lists every delivery attempt for the endpoint, with the event type, delivery status, number of **Attempts**, and the created, started, and completed timestamps. Click a row to open **Webhook Event Data**, which shows the stored event record as formatted JSON with a **Copy JSON** button. Use **Refresh** to pull the latest deliveries, and the search bar to find a specific event. Failed deliveries are retried automatically. For the retry schedule and how to make your handler safe to call more than once, see [handling retries](/start/webhooks/handling-retries). ### Documentation The **Documentation** entry in the sidebar opens these docs ([docs.sent.dm](https://docs.sent.dm)) in a new tab: your reference for guides, API specs, and SDK documentation. ### Compliance Page The [Compliance](https://app.sent.dm/dashboard/country-compliance) page is where you can view and manage your compliance details across four tabs: - **Brand**: view your business and contact information submitted during the KYC process, including business details, address, and tax information - **Messaging**: view your registered 10DLC campaigns. Each campaign lists its description, the use cases it covers (for example Marketing, Account Notification, Customer Care, 2FA, Delivery Notification), and the sample messages submitted for each one - **Documents**: view and manage your uploaded compliance documents - **Auto Reply**: manage the automatic replies that fire on opt keywords. Each entry pairs a category (**OPT OUT**, **OPT IN**, **HELP**, or **OTHER**) with one or more keywords and the reply text sent when a contact texts one of them. The table lists Category, Keywords, Reply Text, Status, and Updated At; use **Add Auto Reply** to create an entry, and the row actions to edit or delete one. Keywords and their replies are configured together here. Some rows are marked **Protected** and have no row actions. These are the entries holding a category's carrier-mandated keyword (`STOP` for opt-out, `START` for opt-in, `HELP` for help), which is seeded onto every account and answers consent signals that arrive with no keyword behind them, such as a provider-signalled opt-out. They can't be edited or deleted. You can still add your own entries to those same categories, and those behave normally. **OTHER** mandates nothing, so its entries are never protected. A row awaiting approval shows **Pending** in Status and a lock instead of the edit action until it's approved.
### Channels Page The [Channels](https://app.sent.dm/dashboard/channels) page is where you configure your messaging channels, split across two tabs: - **SMS tab**: set your default **alphanumeric Sender ID** (used for all outgoing SMS messages across configured countries) and manage **Destination Countries**. The countries table shows each country with its current sender identity (for example, short code or dedicated number); use **Add Country** to enable a new destination or request a dedicated sender identity per country. - **WhatsApp tab**: connect and manage your WhatsApp Business Account (WABA) configuration. ### Users Page The [Users](https://app.sent.dm/dashboard/users) page is where you manage who has access to your Sent organization and what they can do. Click **Invite Users** to add a new team member by email. The **Organization Users** table lists every member with the following columns: - **NAME**: the user's full name - **EMAIL**: the email tied to their account - **ROLE**: their assigned role in the organization - **STATUS**: Active, Invited, or Disabled - **LAST SEEN**: when they were last active in the dashboard - **ACTIONS**: edit role or remove from the organization Use the **Search** bar and **Filter** menu to quickly find a user. Supported roles are: | Role | Description | |------|-------------| | **Owner** | Full access to all settings and billing | | **Admin** | Full access except billing ownership transfer | | **Billing** | Access to billing and payment settings only | | **Developer** | Access to API keys, webhooks, and development tools | ### Settings Page The [Settings](https://app.sent.dm/dashboard/settings) page is where you can update your organization's account information: - **Business Info**: update your legal business name, tax ID type, and tax ID - **Details**: additional organization details and account configuration When you switch the context switcher from your organization to an individual Sender Profile, two more tabs appear, scoped to that profile: - **Basic Info**: the profile's name, short name, and description - **Data Sharing**: whether the profile shares its contacts and message templates with other profiles, and whether it inherits them from the organization ### Billing Page The [Billing](https://app.sent.dm/dashboard/billing) page is where you manage your Sent account's finances. It includes four tabs: - **Overview**: view your current credit **Balance**, top up manually with **Add to credit balance**, and configure **Auto Charge** to keep your balance funded automatically - **Payment Methods**: add or change your payment method - **Billing History**: view past and current invoices - **Preferences**: manage billing information and notification preferences #### Auto Charge Auto Charge automatically tops up your credit balance by charging your default payment method whenever your balance falls below a threshold you set. This prevents service interruptions when credits run low. To configure it, click the gear icon on the Overview tab: | Setting | Description | |---|---| | **When credit balance reaches** | The minimum balance threshold that triggers a top-up (for example, `$10.00`) | | **Recharge amount** | How much to charge your card when the threshold is hit (for example, `$100.00`, max `$10,000`) | Once enabled, a status banner on the Overview tab confirms the active settings. You can update or turn off Auto Charge at any time from the same gear icon. #### Failed payment alert If your last payment didn't go through, a red alert banner appears at the top of the main Dashboard page: > *Your last payment didn't go through. Please **update your payment method or add funds** to avoid any interruption to your service.* Clicking the link takes you directly to the Billing page. The banner clears automatically once a payment succeeds. #### Payment email notifications Sent sends transactional emails for Auto Charge and manual top-up events: | Event | Subject | Key content | |---|---|---| | **Payment failed** | `Your payment didn't go through` | Decline reason, **Update payment method** CTA linking to Billing | | **Payment succeeded** | `Payment received — $X.XX added` | Amount added, new balance, transaction reference | For Auto Charge failures, the email body states that Sent tried to automatically top up your account with $X.XX, but the payment didn't go through. For manual top-up failures, the email states that Sent couldn't process your $X.XX top-up payment. ### Help The **Help** section at the bottom of the sidebar gives you two quick ways to get assistance: - **email support**: opens your mail client to send a message to [support@sent.dm](mailto:support@sent.dm) - **Help Center**: opens the [help center](https://help.sent.dm) in a new tab for guides, FAQs, and troubleshooting articles --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/quickstart/first-message.txt TITLE: Send Your First Message ================================================================================ URL: https://docs.sent.dm/llms/start/quickstart/first-message.txt Make your first Sent API call to send an SMS, WhatsApp, or RCS message: get your API key, send with a template, read the response, and track delivery status. # Send Your First Message Make your first API call to send an SMS, WhatsApp, or RCS message. **What you'll accomplish**: Get API keys → Send a message → Track delivery **⏳ Time required**: ~2 minutes ## Prerequisites **Light onboarding path** (email + phone verified): - [ ] [email and phone verified](/start/try-sent) - [ ] API key copied from dashboard - [ ] Pre-built template ID copied from dashboard **Full production setup path**: - [ ] [Account set up and KYC approved](/start/quickstart/account-setup) - [ ] [Channels configured](/start/quickstart/channel-setup) - [ ] [Custom template created and approved](/start/quickstart/first-template) - [ ] API key copied from dashboard ## How Message Sending Works ## Get Your API Keys From your [Sent Dashboard](https://app.sent.dm/dashboard/api-keys), copy your API key: **Security reminder:** Never commit API keys to version control. Use environment variables. ## Send a Message Choose your preferred method: ```bash # Set your API key and template ID export SENT_API_KEY="your_api_key_here" export TEMPLATE_ID="your_template_id_here" export RECIPIENT="+1234567890" curl -X POST "https://api.sent.dm/v3/messages" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["'"$RECIPIENT"'"], "template": { "id": "'"$TEMPLATE_ID"'" }, "channel": ["sms", "whatsapp", "rcs"] }' ``` ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // Uses SENT_DM_API_KEY env var const response = await client.messages.send({ to: ['+1234567890'], template: { id: 'your_template_id_here' }, channel: ['sms', 'whatsapp', 'rcs'] }); console.log('Message sent:', response.data.recipients[0].message_id); ``` ```python from sent_dm import SentDm client = SentDm() # Uses SENT_DM_API_KEY env var response = client.messages.send( to=["+1234567890"], template={ "id": "your_template_id_here" }, channel=["sms", "whatsapp", "rcs"] ) print(f"Message sent: {response.data.recipients[0].message_id}") ``` ```go package main import ( "context" "fmt" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) func main() { client := sentdm.NewClient( option.WithAPIKey("your_api_key_here"), ) response, err := client.Messages.Send(context.Background(), sentdm.MessageSendParams{ To: []string{"+1234567890"}, Channel: []string{"sms", "whatsapp", "rcs"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("your_template_id_here"), }, }) if err != nil { panic(err) } fmt.Println("Message sent:", response.Data.Recipients[0].MessageID) } ``` ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import dm.sent.models.messages.MessageSendParams; SentDmClient client = SentDmOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addChannel("sms") .addChannel("whatsapp") .addChannel("rcs") .template(MessageSendParams.Template.builder() .id("your_template_id_here") .build()) .build(); var response = client.messages().send(params); System.out.println("Message sent: " + response.data().recipients().get(0).messageId()); ``` ```csharp using Sentdm; using Sentdm.Models.Messages; using System.Collections.Generic; SentDmClient client = new(); // Uses SENT_DM_API_KEY env var MessageSendParams parameters = new() { To = new List { "+1234567890" }, Channels = new List { "sms", "whatsapp", "rcs" }, Template = new MessageSendParamsTemplate { Id = "your_template_id_here" } }; var response = await client.Messages.Send(parameters); Console.WriteLine($"Message sent: {response.Data.Recipients[0].MessageId}"); ``` ```php messages->send( to: ['+1234567890'], template: [ 'id' => 'your_template_id_here' ], channels: ['sms', 'whatsapp', 'rcs'] ); echo "Message sent: " . $result->data->recipients[0]->message_id . "\n"; ``` ```ruby require "sentdm" sent_dm = Sentdm::Client.new( api_key: ENV["SENT_DM_API_KEY"] ) result = sent_dm.messages.send( to: ["+1234567890"], template: { id: "your_template_id_here" }, channels: ["sms", "whatsapp", "rcs"] ) puts "Message sent: #{result.data.recipients[0].message_id}" ``` Use the [Sent Dashboard Playground](https://app.sent.dm/dashboard/playground): 1. Select your template 2. Enter recipient phone number 3. Choose channels (SMS/WhatsApp/RCS) 4. Click "Send Message" ## Understanding the Response **Success (HTTP 202):** ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "sms" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-03-04T11:28:25.2096416+00:00", "version": "v3" } } ``` **Key fields:** - `recipients[].message_id` - Save this to track the message - `recipients[].channel` - Which channel was used (automatically selected) - `status` - `QUEUED` on success; the message is now in the sending pipeline - `meta.request_id` - Use this for support inquiries ## Track Delivery Status ### Via Dashboard View message status in the [Activities page](https://app.sent.dm/dashboard/activities): ### Via Webhooks (Recommended) Set up [webhooks](/start/webhooks/getting-started) to receive real-time status updates: ```json { "field": "message", "event": "message.delivered", "timestamp": "2026-01-15T10:35:00Z", "payload": { "updated_at": "2026-01-15T10:35:00Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+14155551234", "message_status": "DELIVERED", "channel": "sms" } } ``` The full envelope and payload schemas are in the [Events Reference](/start/webhooks/event-types). ### Via API Query message status: ```bash curl "https://api.sent.dm/v3/messages/{message_id}" \ -H "x-api-key: $SENT_API_KEY" ``` Refer to the [Message Status Tracking guide](/start/guides/message-status-tracking) for the full status lifecycle and tracking patterns, and to the [Sending Messages guide](/start/guides/sending-messages) and the [Send a message API reference](/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint) for all send options, including channel selection and template variables. ## Sandbox Mode Test without sending real messages by adding `"sandbox": true` to your request body: ```json { "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, "channel": ["sms"], "sandbox": true } ``` The API validates your request and returns a realistic fake response **without executing any side effects**: it writes nothing to the database, sends no message, and calls no external API. Look for the `X-Sandbox: true` header in the response. ## Next Steps You've sent your first message. Here's what to explore next: ## Troubleshooting **401 Unauthorized (AUTH_002)?** - Check your API key is correct - Ensure the `x-api-key` header is set (not `x-sender-id` - that's v2 legacy) **Message shows `BLOCKED` (insufficient balance)?** - The v3 API accepts the send with `202`, then blocks the message asynchronously when your balance is too low - Add funds or a payment method in [Billing](https://app.sent.dm/dashboard/billing) **400 Bad Request?** - Verify template ID is correct - Check phone number format (E.164: +1234567890) - Ensure template is approved (for WhatsApp) **Message stuck in "queued" status?** - Normal for first few seconds - Check [Activities page](https://app.sent.dm/dashboard/activities) - Set up webhooks for real-time updates --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/quickstart/first-template.txt TITLE: Create Your First Template ================================================================================ URL: https://docs.sent.dm/llms/start/quickstart/first-template.txt Create your first message template in the Sent Dashboard: add dynamic variables, submit for WhatsApp and RCS approval, and get the template ID for API sends. # Create Your First Template Templates are reusable message blueprints that enable consistent, personalized communication across SMS, WhatsApp, and RCS. **What you'll accomplish**: Create a template → Customize content → Submit for approval → Get template ID **⏳ Time required**: ~5 minutes **Using light onboarding?** You already have access to 6 pre-built OTP and verification templates, no custom template needed to get started. Find them in your dashboard under **Templates**. Create a custom template here when you're ready to go beyond those defaults (requires completed [account setup](/start/quickstart/account-setup)). ## What is a Template? A template in Sent is: - A reusable message format with dynamic variables - Automatically adapted for SMS, WhatsApp, and RCS - Submitted for channel approval (WhatsApp via Meta, RCS via your RCS Sender Profile) - Referenced by ID when sending messages ## Navigate to Templates Go to the [Templates page](https://app.sent.dm/dashboard/templates) in your Sent Dashboard. ## Choose Creation Method You have several options: | Method | Best For | |--------|----------| | **Create from Sample** | Quick start with pre-designed templates | | **Create from Scratch** | Full customization control | | **Import from Meta** | Using existing WhatsApp templates | | **From JSON Definition** | Programmatic template creation | For this guide, we'll use **"Create from Sample"**. ## Select a Sample Template Choose a template that matches your use case: Recommended for beginners: - **"Hello with SMS, WhatsApp, and RCS"** - Simple greeting template - **"Order Confirmation"** - E-commerce notification - **"Appointment Reminder"** - Service business reminder Click **"Use Sample"** to proceed. ## Customize Your Template Modify the template content for your business: Templates are built from a body plus optional header, footer, and buttons; component support differs per channel. Refer to the [Working with Templates guide](/start/guides/working-with-templates) for the full component and channel-support matrix. ### Dynamic Variables Use variables for personalized content: ``` Hello {{customerName}}, your order {{orderNumber}} has been shipped! ``` Variables are shown in yellow boxes in the editor. ## Submit for Review Once satisfied with your template, click **"Submit for Review"**: WhatsApp templates require Meta approval (typically 24-48 hours). You can still send SMS messages using the template while waiting for approval. Template statuses: - **Draft** - Editing in progress - **Pending** - Submitted for review - **Approved** - Ready to use - **Rejected** - Needs modifications ## Get Your Template ID Once created, copy your template ID: You'll need this ID when sending messages via the API: ```json { "to": ["+1234567890"], "template": { "id": "YOUR_TEMPLATE_ID" } } ``` ## Template Best Practices ### Content Guidelines **Do:** - Keep messages concise and clear - Use variables for personalization - Include clear call-to-action - Test on SMS, WhatsApp, and RCS **Don't:** - Use promotional language in utility templates - Include excessive punctuation (!!!) - Use all caps - Include URLs in the body (use button links instead) ### Character Limits Character limits differ per channel and component. Refer to [SMS Encoding & Message Length](/start/concepts/sms-encoding-and-length) for SMS segment rules and to the [Working with Templates guide](/start/guides/working-with-templates) for per-channel template limits. ## What's Next? Now that you have a template: 1. **[Send your first message](/start/quickstart/first-message)** - Use your template to send a message 2. **[Learn more about templates](/start/guides/working-with-templates)** - Advanced template features 3. **[Set up webhooks](/start/webhooks/getting-started)** - Track template approval status ## Troubleshooting **Template rejected?** Common reasons: - Violates Meta's commerce policy - Contains promotional content in utility category - Poor grammar or spelling - Missing context **Template stuck in "Pending" status?** - Normal for WhatsApp approval (24-48 hours) - Use SMS in the meantime - Check [template status in dashboard](https://app.sent.dm/dashboard/templates) --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/quickstart.txt TITLE: Quickstart Overview ================================================================================ URL: https://docs.sent.dm/llms/start/quickstart.txt Send your first message in minutes with light onboarding: a verified email and phone gets you API access, six pre-built templates, and 500 sends per day. # Quickstart Overview Send your first message in minutes. A verified email address and phone number are enough to start; compliance paperwork comes later. **Light onboarding is live.** All you need is a verified email and phone number to access the API, 6 pre-built templates, and 500 sends/day per template. Compliance comes later, when you're ready to create your own templates or send from your own number. ## Start Here --- ## Ready for Production? When you want to create your own templates or send from your own number, complete the full setup: ### Account Setup Create your account and complete KYC verification. [Learn more →](/start/quickstart/account-setup) ⏱️ ~10 minutes - Sign up at [app.sent.dm](https://app.sent.dm) - Verify your phone number via WhatsApp - Complete business verification (KYC) - Set up billing ### Channel Setup Configure your SMS, WhatsApp, and RCS sender. [Learn more →](/start/quickstart/channel-setup) ⏱️ ~5 minutes - Select your business phone number - Connect WhatsApp Business account - Enable RCS messaging - Configure payment methods ### Create Your First Template Build a custom message template. [Learn more →](/start/quickstart/first-template) ⏱️ ~5 minutes - Choose from samples or build from scratch - Add dynamic variables - Submit for approval ### Send Your First Message Make your first API call. [Learn more →](/start/quickstart/first-message) ⏱️ ~2 minutes - Get your API keys from the dashboard - Send via API, SDK, or Playground - Track delivery status ## What Full Setup Unlocks | | Light Onboarding | Full Setup | |---|:---:|:---:| | Pre-built templates (OTP & verification) | 6 | 6 + custom | | Sends per day (per template) | 500 | Unlimited | | REST API | Yes | Yes | | Custom templates | No | Yes | | Your own sender number | No | Yes | ## Next Steps After Setup - **[Explore the Dashboard](/start/quickstart/dashboard-walkthrough)**: Learn all dashboard features - **[Set up Webhooks](/start/webhooks/getting-started)**: Receive real-time delivery notifications - **[Install an SDK](/sdks)**: Integrate Sent into your app - **[Read the Concepts](/start/concepts)**: Understand the architecture ## Need Help? - [Troubleshooting](/start/reference-guides/troubleshooting): Common issues and solutions - [email support](mailto:support@sent.dm): contact the Sent team --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/reference-guides/faq.txt TITLE: Frequently Asked Questions ================================================================================ URL: https://docs.sent.dm/llms/start/reference-guides/faq.txt Answers to common questions about Sent: getting started without KYC, pricing and billing, messaging and templates, webhooks, security, and technical limits. # Frequently Asked Questions ## Getting Started ### Do you need to complete KYC to start sending? No. **Light onboarding** lets you start sending immediately with just a verified email and phone number: - 6 pre-built OTP and verification templates - 500 sends/day per template - Full REST API access - Send to any phone number Full account verification is required when you want to create your own custom templates or send from your own number. [Start with light onboarding →](/start/try-sent) ### How long does setup take? **Light onboarding**: ~2 minutes to verify your email and phone. **Full production setup**: 10-15 minutes: - Account creation: 2 minutes - KYC verification: 5-10 minutes (usually approved within hours) - Channel setup: 3-5 minutes - First message: 2 minutes ### Do you need a Meta Business account? Only if you want to send **WhatsApp messages**. SMS works without Meta. You can create a Meta Business Portfolio during the WhatsApp setup process. ### Is there a free trial? Yes, and you can start sending real messages immediately: - **Light onboarding**: Verify your email and phone and get 6 pre-built templates, 500 real sends/day, and full API access. No credit card required. [Start here →](/start/try-sent) - **Playground**: Send messages directly from the dashboard with no code - see the [Playground page](/start/quickstart/dashboard-walkthrough#playground-page) walkthrough. - **Sandbox mode**: Add `"sandbox": true` to any API request to validate without sending real messages or incurring charges. ## Pricing & Billing ### How much does Sent cost? Pricing is usage-based: you pay for the contacts you message plus carrier pass-through fees, which vary by channel and destination country. See the [Sent pricing page](https://sent.dm/pricing) for current rates and an interactive cost calculator. ### What happens if you run out of balance? Your send requests are still accepted with a `202` response, but each message is finalized as `BLOCKED` instead of being dispatched. The `BLOCKED` status appears on `GET /v3/messages/{id}` and arrives as a `message.blocked` webhook, and you are not charged. Add credits manually from the [Billing page](https://app.sent.dm/dashboard/billing), or enable **Auto Charge** to top up automatically before your balance hits zero. See [Auto Charge](/start/quickstart/dashboard-walkthrough#auto-charge) in the dashboard walkthrough for setup instructions. Only the legacy v2 send endpoints reject an out-of-balance request synchronously with a `402` error and code `BUSINESS_003`. ### Why did your Auto Charge payment fail? The most common reasons are an expired card, insufficient funds, or your bank blocking an off-session (automatic) charge. If a payment fails, you'll receive a **"Your payment didn't go through"** email with the decline reason, and a red alert banner will appear on your Dashboard. Go to [Billing → Payment Methods](https://app.sent.dm/dashboard/billing) to update your card. Auto Charge resumes automatically once a valid method is on file. ### Do you offer volume discounts? Yes. Contact [sales@sent.dm](mailto:sales@sent.dm) for custom pricing on high volumes (1M+ messages/month). ## Messaging ### What's the difference between SMS, WhatsApp, and RCS? | Feature | SMS | RCS | WhatsApp | |---------|-----|-----|----------| | **Delivery** | Universal (all phones) | Android (Google Messages), carrier-native | Requires WhatsApp app | | **Rich content** | Text only | Images, carousels, buttons, branded sender | Images, buttons, videos | | **Read receipts** | No | Yes (carrier dependent) | Yes | | **Cost** | Higher per message | Varies by carrier | Lower per conversation | | **Approval** | Template approval required | Template approval required | Template approval required | | **App required** | No | No | Yes | ### How fast are messages delivered? - **Queued to Sent:** < 100 ms - **Sent to provider:** < 1 second - **Delivered to device:** 1-30 seconds (carrier dependent) ### Can you send messages internationally? Yes. Sent supports messaging to 200+ countries. Compliance requirements vary by destination - check the [Compliance Guide](/start/advanced/compliance-regulations). ## Templates ### Does RCS require template approval? Yes. Like WhatsApp, RCS requires template approval before you can send. All channels go through the same onboarding and template approval procedures. ### How long does WhatsApp template approval take? Typically **24-48 hours**. You can send SMS messages using the template while waiting for approval. ### Why was your template rejected? Common reasons: - Promotional content in utility category - Violating Meta's commerce policies - Missing context or poor grammar - URLs in body (use buttons instead) ### Can you edit an approved template? No. Approved templates are locked to ensure consistency. Create a new template version instead. ## Webhooks ### Do you need webhooks? Not required, but **strongly recommended** for: - Delivery confirmations - Real-time status updates - Template approval notifications - Failed message handling ### What URL should you use for webhooks? Your webhook endpoint must be: - Publicly accessible HTTPS URL - Return 2xx status codes - Respond within 5 seconds ### Can you test webhooks locally? Yes. Use [ngrok](/start/webhooks/local-development) or similar tools to expose your local server. ## Technical ### Which SDK should you use? Choose based on your stack: - **TypeScript/Node.js:** `@sentdm/sentdm` - **Python:** `sentdm` - **Go:** `github.com/sentdm/sent-dm-go` - **Other languages:** Raw HTTP or community SDKs ### What's the API rate limit? Limits are enforced per minute and differ between standard and sensitive endpoints. See the [rate limits reference](/reference/api/rate-limits) for the current limits, response headers, and backoff guidance. ### How do you handle errors? See the [Error Handling Guide](/start/guides/error-handling) for retry strategies, circuit breakers, and best practices. ## Security ### Is Sent SOC 2 compliant? Yes, Sent is SOC 2 Type II certified. Contact Sent for compliance documentation. ### Where is data stored? Data is stored in AWS regions (US and EU). Contact Sent for data residency requirements. ### How do you rotate your API keys? 1. Generate new key in [Dashboard](https://app.sent.dm/dashboard/api-keys) 2. Update your app 3. Deploy changes 4. Delete old key ## Support ### How do you get help? 1. Check this FAQ 2. Review [Troubleshooting](/start/reference-guides/troubleshooting) 3. Search [documentation](/docs) 4. email [support@sent.dm](mailto:support@sent.dm) ### What's the support response time? Response times vary by plan. See [support response times](/start/reference-guides/support#support-response-times) for the current matrix. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/reference-guides.txt TITLE: Help & Reference Overview ================================================================================ URL: https://docs.sent.dm/llms/start/reference-guides.txt Quick answers when you need help with Sent: frequently asked questions, problem-solution troubleshooting guides, error code references, and support channels. # Help & Reference Overview Quick answers and resources when you need help with Sent. ## What do you need help with? ## Quick Reference | Resource | Link | |----------|------| | **Error Codes** | [Error Catalog](/reference/api/error-catalog) | | **API Reference** | [API Documentation](/reference/api) | | **SDKs** | [SDK Guides](/sdks) | | **Glossary** | [Terms & Definitions](/reference/glossary) | | **Changelog** | [Platform Updates](/reference/changelog) | ## Search by Error Common errors and quick fixes: | Error | Quick Fix | |-------|-----------| | `401 Unauthorized` | [Check API key](/start/reference-guides/troubleshooting) | | `429 Rate Limited` | [Implement backoff](/start/guides/error-handling) | | `Template not approved` | [Check template status](/start/guides/working-with-templates) | | `Insufficient balance` | [Add payment method](https://app.sent.dm/dashboard/billing) | ## Support Channels ### Self-Service - [Documentation](/docs) - Complete guides - [API Reference](/reference/api) - Endpoint details - [Error Catalog](/reference/api/error-catalog) - Error codes ### Community - [Sent on GitHub](https://github.com/sentdm) - SDK repositories and issue trackers ### Direct Support - **email:** [support@sent.dm](mailto:support@sent.dm) - **Response times:** Vary by plan - see [support response times](/start/reference-guides/support#support-response-times) **Submitting a support ticket?** Include your request ID, timestamp, and code snippet (with API keys removed) for faster resolution. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/reference-guides/support.txt TITLE: Support & Developer Resources ================================================================================ URL: https://docs.sent.dm/llms/start/reference-guides/support.txt Get help with Sent: support channels and response times, plus changelog, glossary, error catalog, troubleshooting guides, and FAQ resources for developers. # Support & Developer Resources Developer support resources, troubleshooting guides, and reference materials to help you get the most out of Sent's messaging platform. ## Support Resources ## Getting Help ### Self-Service Resources - [Troubleshooting](/start/reference-guides/troubleshooting) - common issues and step-by-step solutions - [FAQ](/start/reference-guides/faq) - answers to frequent setup, billing, and messaging questions - [API reference](/reference/api) - complete endpoint documentation - [SDKs and integration guides](/sdks) - code examples and implementation patterns - [API Status](https://status.sent.dm) - real-time service status and incident reports ### Contact Support email [support@sent.dm](mailto:support@sent.dm) for technical questions and integration help. Include the request ID (`meta.request_id`), the error code, and a code snippet with API keys removed - see [what to include in a support request](/start/reference-guides/troubleshooting#getting-support). For volume pricing or custom plans, contact [sales@sent.dm](mailto:sales@sent.dm). ### Support Response Times | Plan | Response time | Channel | |------|---------------|---------| | Starter/Growth | 24-48 hours | email | | Scale | 8-12 hours | email | | Enterprise | 2-4 hours | dedicated channels | Need immediate help? Check the [Error Reference](/reference/api/errors) and [Error Catalog](/reference/api/error-catalog) for the fastest resolution to common issues. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/reference-guides/troubleshooting.txt TITLE: Troubleshooting Common Issues ================================================================================ URL: https://docs.sent.dm/llms/start/reference-guides/troubleshooting.txt Quick problem-to-solution fixes for common Sent issues: authentication errors, message sending failures, template rejections, webhooks, and channel setup. # Troubleshooting Common Issues Common issues and step-by-step solutions. ## Authentication Issues ### 401 Unauthorized **Symptoms:** API returns `401 Unauthorized` with error code `AUTH_002` (invalid or missing API key) **Solutions:** 1. Verify your API key is correct 2. Check the `x-api-key` header is set (not `x-sender-id` - that's v2 legacy) 3. Ensure no extra whitespace in the key 4. Verify the key hasn't been revoked ```bash # Test your API key curl -H "x-api-key: YOUR_API_KEY" \ https://api.sent.dm/v3/templates ``` ### 403 Forbidden **Symptoms:** API returns `403 Forbidden` with an `AUTH_004`–`AUTH_007` error code **Solutions:** 1. `AUTH_004`: your API key doesn't have permission for this operation; use a key with the required access 2. `AUTH_005`: account onboarding is not complete; finish onboarding in the [dashboard](https://app.sent.dm) 3. `AUTH_006`: KYC verification is not complete; submit KYC and wait for approval 4. `AUTH_007`: channel setup is not complete; finish setup for the channel you're sending on 5. A suspended account also returns `403` with code `BUSINESS_014`; contact support ## Message Sending Issues ### Messages Stuck in "Queued" **Symptoms:** Message status doesn't change from `QUEUED` A message normally leaves `QUEUED` within the first few seconds after a send. If it stays queued longer: **Solutions:** 1. Check KYC status is approved 2. Verify account has sufficient balance 3. Check [dashboard](https://app.sent.dm/dashboard/activities) for errors ### Insufficient Balance **Symptoms:** The send returns `202 Accepted`, but the message is finalized as `BLOCKED` (insufficient balance). The status appears on `GET /v3/messages/{id}/activities` and arrives as a `message.blocked` webhook. Legacy v2 send endpoints instead return `402` with error code `BUSINESS_003`. **Solutions:** 1. Add credits manually from [Billing → Overview](https://app.sent.dm/dashboard/billing) 2. Enable **Auto Charge** (Billing → Overview → gear icon) to automatically top up when your balance falls below a set threshold. This prevents the block from recurring 3. Check [Billing → Billing History](https://app.sent.dm/dashboard/billing) for failed payments ### Payment failed (Auto Charge or manual top-up) **Symptoms:** Red alert banner on the Dashboard reads *"Your last payment didn't go through,"* or you received a **"Your payment didn't go through"** email. **Common causes:** - Card declined (insufficient funds, expired card, or bank block on off-session charges) - No default payment method on file - No Stripe customer record linked to the account **Solutions:** 1. Go to [Billing → Payment Methods](https://app.sent.dm/dashboard/billing) and update or re-add your card 2. After updating, add credits manually to confirm the new payment method works 3. Auto Charge will resume automatically on the next trigger once a valid payment method is on file 4. If the problem persists, contact [support@sent.dm](mailto:support@sent.dm) with the decline reason from the email ### 429 Rate Limited (BUSINESS_002) **Symptoms:** API returns `429` with error code `BUSINESS_002` **Rate Limits:** - Standard endpoints: 200 requests per minute - Sensitive endpoints: 10 requests per minute **Solutions:** 1. Implement exponential backoff 2. Reduce request frequency 3. Consider batching requests 4. Check rate limit headers for reset time ```typescript // Implement backoff const retryAfter = error.headers['retry-after'] || 60; await sleep(retryAfter * 1000); ``` ### Invalid Phone Number (VALIDATION_002) **Symptoms:** Error code `VALIDATION_002` **Solutions:** 1. Use E.164 format (+1234567890) 2. Include country code 3. Remove spaces and special characters 4. Validate before sending ```typescript function validateE164(phone: string): boolean { return /^\+[1-9]\d{1,14}$/.test(phone); } ``` ## Template Issues ### Template Not Found (RESOURCE_002) **Symptoms:** Error code `RESOURCE_002` **Solutions:** 1. Verify template ID is correct (use `template.id` in request body, not `template_id`) 2. Check template exists in [dashboard](https://app.sent.dm/dashboard/templates) 3. Ensure you're using the right account ### Template Pending Approval **Symptoms:** WhatsApp messages not sending **Solutions:** 1. Wait for Meta approval (24-48 hours) 2. Use SMS in the meantime 3. Check [template status](https://app.sent.dm/dashboard/templates) 4. Review rejection reason if applicable ### Template Rejected **Symptoms:** Template status `rejected` **Solutions:** 1. Review Meta's feedback 2. Fix identified issues 3. Resubmit template 4. Contact support if unclear ## Webhook Issues ### Webhooks Not Received **Symptoms:** No webhook events arriving **Solutions:** 1. Verify webhook URL is correct 2. Check endpoint returns 2xx status 3. Ensure endpoint responds within 5 seconds 4. Verify HTTPS is working 5. Check webhook is enabled in dashboard ### Duplicate Webhooks **Symptoms:** Same event received multiple times **Solutions:** 1. Implement idempotency using event ID 2. Store processed event IDs 3. Use database transactions See [Handling Retries](/start/webhooks/handling-retries). ### Webhook Signature Invalid **Symptoms:** Signature verification fails **Solutions:** 1. Use raw request body (not parsed JSON) 2. Check secret key is correct 3. Use constant-time comparison 4. Verify timestamp is recent ## Channel Issues ### WhatsApp Not Working **Symptoms:** WhatsApp messages failing **Solutions:** 1. Verify WhatsApp Business account is connected 2. Check template is approved 3. Confirm recipient has WhatsApp 4. Verify Meta Business account is in good standing ### SMS Delivery Failed **Symptoms:** SMS messages failing **Solutions:** 1. Check phone number format 2. Verify recipient hasn't opted out 3. Check for carrier blocks 4. Review message content compliance ## Performance Issues ### Slow API Responses **Symptoms:** API calls taking > 5 seconds **Solutions:** 1. Check your network connection 2. Verify server location (use closest region) 3. Implement connection pooling 4. Check for rate limiting ### High Error Rate **Symptoms:** > 5% of requests failing **Solutions:** 1. Check error logs for patterns 2. Review recent code changes 3. Verify API key validity 4. Check account status ## Dashboard Issues ### Can't Access Dashboard **Solutions:** 1. Check [API Status](https://status.sent.dm) for ongoing incidents 2. Verify your account hasn't been suspended - suspended accounts also receive `403` responses with code `BUSINESS_014` on API calls 3. If access still fails, email [support@sent.dm](mailto:support@sent.dm) with your account email and the time of the failure ### Data Not Loading **Solutions:** 1. Check [API Status](https://status.sent.dm) for ongoing incidents 2. Check the browser console for JavaScript errors and include them in your support ticket 3. Contact [support@sent.dm](mailto:support@sent.dm) if the problem persists ## Debugging Steps ### General Debugging Process 1. **Enable debug logging** ```typescript const client = new SentDm({ logLevel: 'debug' }); ``` 2. **Check request/response** ```typescript console.log('Request:', request); console.log('Response:', response); console.log('Error:', error); ``` 3. **Test in sandbox mode** ```typescript await client.messages.send({ to: ['+1234567890'], template: { id: 'tmpl_123' }, sandbox: true }); ``` 4. **Check dashboard logs** - [Activities](https://app.sent.dm/dashboard/activities) - [Webhook logs](https://app.sent.dm/dashboard/webhooks) ### Getting Support When contacting support, include: 1. Request ID (from `meta.request_id` in error response) 2. Timestamp of issue (from `meta.timestamp`) 3. Error code (from `error.code`) 4. Code snippet (remove API keys) 5. Expected vs actual behavior 6. Steps to reproduce email: [support@sent.dm](mailto:support@sent.dm) --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/try-sent.txt TITLE: Try Sent: Send Your First Message in 2 Minutes, No Code ================================================================================ URL: https://docs.sent.dm/llms/start/try-sent.txt Explore Sent capabilities through the Dashboard Playground and send real messages in minutes with no code required. # Try Sent: Send Your First Message in 2 Minutes, No Code The fastest way to experience Sent is through the Dashboard Playground: send real messages using pre-built templates directly from your browser, no code required. **What you get on sign-up**: 6 pre-built message templates · 500 sends/day · Dashboard Playground · REST API access **Time to first send**: ~2 minutes ## What's Included | Feature | Light Onboarding | Full Setup | |---------|:----------:|:-----------:| | Pre-built OTP & verification templates | 6 | 6 + custom | | Sends per day (per template) | 500 | Unlimited | | Custom variables | Yes | Yes | | Dashboard Playground | Yes | Yes | | REST API access | Yes | Yes | | Create custom templates | No | Yes | | Send from your own number | No | Yes | | Choose destination countries | No | Yes | ## Get Started ### Sign Up Visit [app.sent.dm/sign-up](https://app.sent.dm/sign-up), enter your email, and click the magic link in your inbox. ### Verify Your Phone Number After clicking the magic link, you'll be prompted to verify your phone number. Sent delivers the verification code via WhatsApp, or SMS if WhatsApp is not available for your number. Enter the code you receive to continue. ### Try the Playground Open the [Dashboard Playground](https://app.sent.dm/dashboard/playground). Pick one of the pre-built templates, enter a phone number, and send a real message, all from your browser. The Activity pane on the right updates in real time as your message moves through delivery: queued, sent, delivered. ## Pre-Built Templates The 6 included templates cover the most common OTP and verification use cases. Find them in your dashboard under **Templates**. They support custom variables so you can pass in dynamic codes, names, or expiry times. ## Ready to Go Further? Your API key is already active: find it in the dashboard under **API Keys**. When you're ready to create custom templates or send from your own number, complete the full account setup: --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/event-types.txt TITLE: Events Reference ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/event-types.txt Complete catalog of webhook event types and JSON payloads for all Sent webhook events # Platform Events Reference This section is the definitive source for all webhook event types emitted by Sent. For an end-to-end view of delivery, retries, and the full event catalogue at a glance, see the [Webhooks Lifecycle](/start/webhooks/lifecycle) page. ## Event Structure Every event that your webhook endpoint receives has a consistent envelope: ```json { "field": "message", "event": "message.delivered", "timestamp": "2025-10-31T10:10:42Z", "payload": { "updated_at": "2025-10-31T10:10:41Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "agent_id": "agent_abc123", "message_status": "DELIVERED", "channel": "sms" } } ``` ```json { "field": "message", "event": "message.received", "timestamp": "2025-10-31T10:10:42Z", "payload": { "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "updated_at": "2025-10-31T10:10:40Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "inbound_number": "+1234567890", "outbound_number": "+1987654321", "text": "Hello, I have a question about my order", "channel": "sms", "received_at": "2025-10-31T10:10:40Z" } } ``` ```json { "field": "templates", "timestamp": "2025-10-31T12:18:14Z", "payload": { "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "whatsapp_template_id": "1234567890123456", "status": "APPROVED", "language": "en_US", "category": "UTILITY", "channel": "whatsapp" } } ``` | Field | Type | Description | |-------|------|-------------| | `field` | string | The parent event type: `message` or `templates` | | `event` | string \| omitted | Granular sub-type (for example, `message.delivered`). Present for all message events; omitted for template events | | `timestamp` | string | ISO 8601 timestamp of event creation | | `payload` | object | Nested object containing event-specific data | ## Event Types & Details Your handler receives the full event object: `field`, `event`, `timestamp`, and the nested `payload`. ### `message` Triggered whenever a message's delivery status changes, or when an inbound message is received. Each status transition fires a separate event with a matching `event`. **Outbound message status events:** All outbound status events share one payload shape. Only `event`, `payload.message_status`, and the timestamps change between sub-types; the **Status Definitions** table below lists every pairing. ```json { "field": "message", "event": "message.delivered", "timestamp": "2025-10-31T12:15:42Z", "payload": { "updated_at": "2025-10-31T12:15:42Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "outbound_number": "+1987654321", "message_status": "DELIVERED", "channel": "sms" } } ``` Messages that are held or suppressed by platform policy emit three additional sub-types with the same payload shape: - `message.scheduled`: the send fell inside the recipient's quiet hours and is deferred, not failed. Sent evaluates quiet-hours windows per channel against the rules for the recipient's country and time zone, both derived from the recipient's phone number. The event carries `message_status: "SCHEDULED"`. Sent releases the message automatically when the recipient's quiet hours end and it continues through the normal send pipeline, so later status events (`message.sent`, `message.delivered`, and so on) follow. `SCHEDULED` is not a terminal status. - `message.filtered`: a policy gate suppressed the send before any provider call. Either the recipient opted out or is on your phone-channel suppression list (`ERR_CONSENT_BLOCKED`), or your routing rules denied the send (`ERR_ROUTE_DENIED`). The message is not dispatched. Sent records the `ERR_*` code internally but does not include it in the webhook payload; the send-time codes are listed in [Error handling](/reference/api/errors). - `message.blocked`: an account-level precondition gated the send, such as insufficient balance, an onboarding message quota, or a template that is not approved for sending. The message is not dispatched. **Outbound Message Event Fields (all outbound sub-types):** | Field | Type | Description | |-------|------|-------------| | `field` | string | Parent event type: `message` | | `event` | string | Granular sub-type: one of `message.queued`, `message.routed`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.scheduled`, `message.filtered`, `message.blocked` | | `timestamp` | string | ISO 8601 timestamp of the event | | `payload.updated_at` | string | ISO 8601 timestamp of the status change | | `payload.account_id` | string | Your account UUID | | `payload.message_id` | string | Message UUID, used to look up the message in your DB | | `payload.template_id` | string \| null | UUID of the template used (null if no template) | | `payload.template_name` | string \| omitted | Name of the template used (omitted when not resolved) | | `payload.outbound_number` | string | Recipient phone number (E.164 format) | | `payload.agent_id` | string \| omitted | Identifier of the agent that originated the message (omitted when not set) | | `payload.message_status` | string | `QUEUED`, `ROUTED`, `SENT`, `DELIVERED`, `READ`, `FAILED`, `SCHEDULED`, `FILTERED`, `BLOCKED` | | `payload.channel` | string | `sms`, `whatsapp`, or `rcs` | --- **Inbound message event (`message.received`):** Fires when an end-user sends a message to one of your provisioned numbers, for example, a reply to an outbound campaign, a STOP/START/HELP keyword on SMS, or a free-text reply on WhatsApp. The payload shape is different from outbound status events: ```json { "field": "message", "event": "message.received", "timestamp": "2025-10-31T10:10:42Z", "payload": { "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "updated_at": "2025-10-31T10:10:40Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "inbound_number": "+1234567890", "outbound_number": "+1987654321", "text": "Hello, I have a question about my order", "channel": "sms", "received_at": "2025-10-31T10:10:40Z" } } ``` ```json { "field": "message", "event": "message.received", "timestamp": "2025-10-31T10:10:42Z", "payload": { "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "updated_at": "2025-10-31T10:10:40Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "inbound_number": "+1234567890", "outbound_number": "+1987654321", "text": "Thanks, got your message!", "channel": "whatsapp", "received_at": "2025-10-31T10:10:40Z" } } ``` ```json { "field": "message", "event": "message.received", "timestamp": "2025-10-31T10:10:42Z", "payload": { "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "updated_at": "2025-10-31T10:10:40Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "inbound_number": "+1234567890", "outbound_number": "+1987654321", "text": "Tapped: View order", "channel": "rcs", "received_at": "2025-10-31T10:10:40Z" } } ``` **Inbound Message Event Fields (`message.received`):** | Field | Type | Description | |-------|------|-------------| | `field` | string | Parent event type: `message` | | `event` | string | Always `message.received` | | `timestamp` | string | ISO 8601 timestamp of the event | | `payload.message_id` | string | UUID of the inbound message record; unique per inbound message | | `payload.updated_at` | string | ISO 8601 timestamp of the message update | | `payload.account_id` | string | Your account UUID | | `payload.inbound_number` | string | Sender's phone number (the contact) in E.164 format | | `payload.outbound_number` | string | Your provisioned number that received the message | | `payload.text` | string \| null | Message body text | | `payload.channel` | string | Channel the message arrived on: `sms`, `whatsapp`, or `rcs` | | `payload.received_at` | string | ISO 8601 timestamp when the provider received the message | **Status Definitions:** | Status | Sub-type | Direction | Description | |--------|----------|-----------|-------------| | `QUEUED` | `message.queued` | Outbound | Message accepted and waiting to be dispatched | | `ROUTED` | `message.routed` | Outbound | Message assigned to a carrier or provider | | `SENT` | `message.sent` | Outbound | Message sent to carrier or WhatsApp | | `DELIVERED` | `message.delivered` | Outbound | Message delivered to recipient's device | | `READ` | `message.read` | Outbound | Message read by recipient (WhatsApp & RCS) | | `FAILED` | `message.failed` | Outbound | Message delivery failed permanently | | `SCHEDULED` | `message.scheduled` | Outbound | Message deferred until the recipient's quiet hours end; re-enters the send pipeline automatically when they do | | `FILTERED` | `message.filtered` | Outbound | Message suppressed by a policy gate (consent opt-out, suppression list, or routing deny); not dispatched | | `BLOCKED` | `message.blocked` | Outbound | Message gated by an account-level precondition (for example, insufficient balance); not dispatched | | `RECEIVED` | `message.received` | Inbound | Inbound message received from a contact |
### `templates` Triggered when a WhatsApp template moves through the approval process. ```json { "field": "templates", "timestamp": "2025-10-31T12:18:14Z", "payload": { "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "whatsapp_template_id": "1234567890123456", "status": "APPROVED", "language": "en_US", "category": "UTILITY", "channel": "whatsapp" } } ``` **Template Event Fields:** | Field | Type | Description | |-------|------|-------------| | `field` | string | Event type: `templates` | | `timestamp` | string | ISO 8601 timestamp of the event | | `payload.account_id` | string | Your account UUID | | `payload.template_id` | string | Template UUID in Sent | | `payload.template_name` | string | Template name | | `payload.whatsapp_template_id` | string | Meta's WhatsApp template ID (empty string until approved) | | `payload.status` | string | Template status, typically `PENDING`, `APPROVED`, `REJECTED`, or `CATEGORY_UPDATED`. Meta may also emit other lifecycle values (for example, `PAUSED`, `DISABLED`) which are forwarded verbatim. | | `payload.language` | string | Template language code (e.g. `en_US`) | | `payload.category` | string | Template category: `MARKETING`, `UTILITY`, `AUTHENTICATION` | | `payload.channel` | string | Channel: `whatsapp` | | `payload.reason` | string \| omitted | Reason supplied by Meta (e.g. rejection reason for `REJECTED`, change description for `CATEGORY_UPDATED`). Omitted when not set. |
## Event Filtering You can configure which events to receive in the webhook settings in your [Sent Dashboard](https://app.sent.dm/dashboard/webhooks) to reduce noise and improve performance. Subscribe to specific event types when creating or updating a webhook by setting `event_types` and, optionally, `event_filters`: ```json { "event_types": ["message", "templates"], "event_filters": { "message": ["delivered", "failed", "read", "received"] } } ``` The `event_filters` map accepts a parent `event_type` as the key and a list of sub-type suffixes as values. In the preceding example only `message.delivered`, `message.failed`, `message.read`, and `message.received` events are delivered. `message.queued`, `message.routed`, and `message.sent` are suppressed. **Available Sub-type Filters for `message`:** | Value | Fires for | Direction | |-------|-----------|-----------| | `queued` | `message.queued` | Outbound | | `routed` | `message.routed` | Outbound | | `sent` | `message.sent` | Outbound | | `delivered` | `message.delivered` | Outbound | | `read` | `message.read` | Outbound (WhatsApp & RCS) | | `failed` | `message.failed` | Outbound | | `scheduled` | `message.scheduled` | Outbound | | `filtered` | `message.filtered` | Outbound | | `blocked` | `message.blocked` | Outbound | | `received` | `message.received` | Inbound | **Available Filters:** - **Message**: Subscribe to all or specific `message.*` sub-types (outbound status + inbound `message.received`) - **Templates**: Filter by specific template names ### Legacy `messages` event type `messages` (plural) is the retired spelling of the `message` parent, from before event types moved to dot-notation sub-types. Webhooks created before the rename may still store `messages` in their `event_types` array; dispatch treats it as an alias of `message`, so those webhooks receive all `message.*` events. You can't subscribe to `messages` when creating or updating a webhook: it is not a registered event type, and the API accepts only the canonical `message`. ## Delivery Headers & Signing Every outgoing webhook request includes the following headers so that you can authenticate and de-duplicate events: | Header | Description | |--------|-------------| | `X-Webhook-ID` | UUID of the webhook configuration that produced the request | | `X-Webhook-Timestamp` | Unix timestamp in seconds at which the request was signed | | `X-Webhook-Signature` | `v1,{base64_hmac}`: HMAC-SHA256 over `{webhook_id}.{timestamp}.{raw_body}` using your signing secret | | `X-Webhook-Event-Type` | Fully qualified event type (`message.delivered`, `templates`, etc.) | Signing secrets are issued in the Sent Dashboard prefixed with `whsec_` (Svix-compatible). Always verify the signature server-side before trusting the payload, and compare the timestamp against your clock to reject replayed requests. ## Delivery Statuses & Retries Each webhook delivery is tracked in the Sent Dashboard with its own row and lifecycle: | Status | Meaning | |--------|---------| | `PENDING` | Event created, queued for delivery | | `RETRYING` | A previous attempt failed; the next attempt is scheduled with backoff | | `DELIVERED` | Endpoint returned `2xx`; consecutive-failure counter resets to `0` | | `FAILED` | Exceeded the webhook's configured retry count (`retry_count`, 1–5, defaults to 3) | **Retry schedule:** a delivery attempt counts as failed when your endpoint returns a non-`2xx` status, the request times out, or the connection fails. Failed attempts are retried with exponential backoff: the first retry fires about one minute after the failure and the delay doubles with each subsequent attempt, up to a maximum of 60 minutes between attempts. Retries stop as soon as your endpoint returns `2xx` or the retry count is exhausted. Per-event metadata stored alongside each attempt includes the delivery attempt count, HTTP status code, the first portion of the response body, the error message, and start/completion timestamps, all surfaced in the Sent Dashboard's webhook event detail view. If a webhook accumulates **10 consecutive failed delivery attempts**, it is automatically disabled. The counter spans events: every failed attempt increments it and any successful delivery resets it to `0`. This guards against expired or compromised endpoints. Re-enable the webhook from the Sent Dashboard once your endpoint is healthy again. This section is the canonical statement of the retry schedule, retry budget, and auto-disable threshold. ## Delivery Constraints Webhook configuration is subject to the following constraints: - **Per-webhook timeout:** 5–120 seconds, defaults to 30 seconds (configured when you create or update the webhook) - **Retry count:** 1–5 attempts per event, defaults to 3 - **URL scheme:** must be `http://` or `https://` - **URL validation:** private IP ranges (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, etc.) are rejected at creation, update, and delivery time --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/getting-started.txt TITLE: Webhook Setup ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/getting-started.txt Register a webhook endpoint in the Sent Dashboard, choose the message and template events to subscribe to, and verify your configuration with a test delivery # Webhook Setup This guide shows you how to register a webhook endpoint in the Sent Dashboard and verify it receives events. **Prerequisites:** - A handler that accepts HTTP `POST` requests and responds with a `2xx` status, reachable from the public internet. If your handler runs on `localhost`, expose it through a tunnel first. See [Local Development & Debugging](/start/webhooks/local-development). ## Events You Can Subscribe To Sent delivers two event types to webhook endpoints: | Event type | What it covers | | :--- | :--- | | `message` | Message status updates: delivery progress, failures, read receipts, and inbound messages | | `templates` | Template lifecycle updates: approvals, rejections, and category changes from review | Refer to the [Events Reference](/start/webhooks/event-types) for the envelope structure and full payloads. ## Add a Webhook ### Open the webhooks page Go to [Webhooks in the Sent Dashboard](https://app.sent.dm/dashboard/webhooks) and click **Add Webhook**.
### Configure the endpoint Fill in the **Add New Webhook** form: | Field | Value | | :--- | :--- | | **Display Name** | A label that identifies this endpoint, for example `Order Notifications`. | | **Endpoint URL** | Your handler's public URL. The dashboard prepends `https://`. URLs that resolve to private or loopback IP ranges are rejected. | | **Select events to listen** | The event types to receive. Selecting a type reveals its sub-types, so you can subscribe to specific events only (for example `delivered` and `failed`). | Click **Add Webhook** to save. To manage webhooks programmatically instead, call [`POST /v3/webhooks`](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint), which accepts the same fields plus `retry_count` (1–5, default 3) and `timeout_seconds` (5–120, default 30). ### Copy the signing secret Click the webhook you just created, then use the **Eye** button to reveal its signing secret or the **Copy** button to copy it. Store the secret in your handler's environment (for example `SENT_WEBHOOK_SECRET`). You need it to [verify request signatures](/start/webhooks/signature-verification) before processing events. ### Send a test delivery From the webhook's actions menu, select **Test Webhook**. The dialog lists every event the webhook subscribes to. Click **Test** next to one, and Sent sends a sample payload for that event to your endpoint. Your webhook is working when: - the test result reports a successful delivery, and - the delivery appears with status `DELIVERED` in the webhook's **Events** table, together with the attempt count and the HTTP status your endpoint returned. If the delivery shows `FAILED`, your endpoint did not return `2xx` in time (test deliveries make a single attempt, with no retries). Work through [Debug Failed Deliveries](/start/webhooks/local-development#debug-failed-deliveries). ## Next Steps - [Security](/start/webhooks/signature-verification): verify the HMAC signature on every request before trusting it - [Handling Retries](/start/webhooks/handling-retries): make your handler idempotent against duplicate deliveries - [Events Reference](/start/webhooks/event-types): envelope structure, payloads, and delivery statuses - [Production Checklist](/start/webhooks/production-checklist): what to confirm before going live ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/handling-retries.txt TITLE: Handling Retries ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/handling-retries.txt Make your Sent webhook handler idempotent: choose a payload-based dedupe key, handle duplicate and out-of-order events, and understand why retries happen. # Handling Retries Sent may deliver the same webhook event multiple times due to retries, network issues, or system recovery. Your handler must be **idempotent** to prevent duplicate processing. ## Why Retries Happen | Scenario | Behavior | |----------|----------| | **No acknowledgment** | Your endpoint didn't return 2xx | | **Timeout** | Your endpoint exceeded the webhook's configured timeout (5–120 s, default 30 s) | | **Network issues** | Connection failed during delivery | | **System recovery** | Event replay after maintenance | ## Choosing an Idempotency Key Sent's webhook delivery does **not** include a per-event unique ID in headers or payload. The `X-Webhook-ID` header is the **webhook configuration UUID** (the same value for every delivery from that webhook), so it cannot be used as a dedupe key. Use one of these instead: - **Inbound message events (`event` = `message.received`)**: `payload.message_id` is unique per inbound message. - **Outbound message events**: `payload.message_id` + `payload.message_status` is unique per state transition. - **Template events**: `payload.template_id` + `payload.status` is unique per transition. - **Generic fallback**: hash the canonical JSON body together with `X-Webhook-Timestamp`. ### Event ID Deduplication Once you've derived an idempotency key from the payload, store it to skip duplicates: ```typescript import crypto from 'crypto'; function idempotencyKey(eventData: any, timestamp: string): string { if (eventData.event === 'message.received') { // Inbound: each inbound message has its own UUID return `in:${eventData.payload.message_id}`; } if (eventData.field === 'message' && eventData.payload.message_id) { // Outbound: state transition is unique return `msg:${eventData.payload.message_id}:${eventData.payload.message_status}`; } if (eventData.field === 'templates') { return `tpl:${eventData.payload.template_id}:${eventData.payload.status}`; } // Fallback: timestamp + canonical body hash const hash = crypto.createHash('sha256').update(JSON.stringify(eventData)).digest('hex'); return `raw:${timestamp}:${hash}`; } async function handleWebhook(eventData: any, timestamp: string) { const key = idempotencyKey(eventData, timestamp); const existing = await db.webhookEvents.findUnique({ where: { idempotencyKey: key } }); if (existing) { console.log(`Event ${key} already processed`); return; } await processEvent(eventData); await db.webhookEvents.create({ data: { idempotencyKey: key, eventType: eventData.field, processedAt: new Date() } }); } ``` ```python import hashlib, json def idempotency_key(event_data: dict, timestamp: str) -> str: if event_data.get('event') == 'message.received': # Inbound: each inbound message has its own UUID return f"in:{event_data['payload']['message_id']}" if event_data.get('field') == 'message' and event_data['payload'].get('message_id'): return f"msg:{event_data['payload']['message_id']}:{event_data['payload']['message_status']}" if event_data.get('field') == 'templates': return f"tpl:{event_data['payload']['template_id']}:{event_data['payload']['status']}" body_hash = hashlib.sha256(json.dumps(event_data, sort_keys=True).encode()).hexdigest() return f"raw:{timestamp}:{body_hash}" async def handle_webhook(event_data: dict, timestamp: str): key = idempotency_key(event_data, timestamp) existing = db.webhook_events.find_unique(where={"idempotency_key": key}) if existing: print(f"Event {key} already processed") return await process_event(event_data) db.webhook_events.create({ "idempotency_key": key, "event_type": event_data['field'], "processed_at": datetime.now() }) ``` ```go import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" ) func idempotencyKey(event WebhookEvent, timestamp string) string { // WebhookEvent maps the envelope: Field `json:"field"`, Event `json:"event"`, Payload `json:"payload"` if event.Event != nil && *event.Event == "message.received" { // Inbound: each inbound message has its own UUID return fmt.Sprintf("in:%s", event.Payload.MessageID) } if event.Field == "message" && event.Payload.MessageID != "" { return fmt.Sprintf("msg:%s:%s", event.Payload.MessageID, event.Payload.MessageStatus) } if event.Field == "templates" { return fmt.Sprintf("tpl:%s:%s", event.Payload.TemplateID, event.Payload.Status) } body, _ := json.Marshal(event) sum := sha256.Sum256(body) return fmt.Sprintf("raw:%s:%s", timestamp, hex.EncodeToString(sum[:])) } func handleWebhook(event WebhookEvent, timestamp string) error { key := idempotencyKey(event, timestamp) var exists bool if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM webhook_events WHERE idempotency_key = $1)", key).Scan(&exists); err != nil { return err } if exists { log.Printf("Event %s already processed", key) return nil } if err := processEvent(event); err != nil { return err } _, err := db.Exec( "INSERT INTO webhook_events (idempotency_key, event_type, processed_at) VALUES ($1, $2, $3)", key, event.Field, time.Now(), ) return err } ``` ### Database Transaction Ensure atomic processing with transactions, keyed by the same payload-derived idempotency key: ```typescript const key = idempotencyKey(eventData, timestamp); await db.$transaction(async (tx) => { // Record event processing start await tx.webhookEvents.create({ data: { idempotencyKey: key, eventType: eventData.field, status: 'processing' } }); // Process business logic if (eventData.field === 'message') { await tx.messages.update({ where: { id: eventData.payload.message_id }, data: { status: eventData.payload.message_status } }); } // Mark as completed await tx.webhookEvents.update({ where: { idempotencyKey: key }, data: { status: 'completed' } }); }); ``` ```python key = idempotency_key(event_data, timestamp) with db.transaction(): # Record event processing start webhook_event = WebhookEvent( idempotency_key=key, event_type=event_data['field'], status="processing" ) db.add(webhook_event) # Process business logic if event_data['field'] == 'message': message = db.messages.find_by_id(event_data['payload']['message_id']) message.status = event_data['payload']['message_status'] # Mark as completed webhook_event.status = "completed" db.commit() ``` ### Message ID Deduplication For message events, use message ID and status to skip stale updates: ```typescript async function handleMessageEvent(eventData: any) { const { message_id, message_status } = eventData.payload; const { timestamp } = eventData; // Get current status from database const message = await db.messages.findById(message_id); // Only update if this is a newer status // Delivery progression only — see the Events Reference for the full message_status list const statusOrder = ['QUEUED', 'ROUTED', 'SENT', 'DELIVERED', 'READ', 'FAILED']; const currentIndex = statusOrder.indexOf(message.status?.toUpperCase()); const newIndex = statusOrder.indexOf(message_status.toUpperCase()); if (newIndex > currentIndex) { await db.messages.update(message_id, { status: message_status }); } } ``` ## Retry Budget Each webhook has a configurable retry budget (`retry_count`). When a delivery fails (non-2xx, timeout, or network error), Sent retries with exponential backoff (the first retry fires about a minute after the failure and the delay grows with each attempt) until either the endpoint returns 2xx or the budget is exhausted, at which point the event is marked `FAILED`. Because retries for a single event can arrive well after the original delivery, size your deduplication window generously (the 7-day window shown later on this page is a safe default). The retry schedule, retry budget, and auto-disable threshold are stated canonically in [Delivery Statuses & Retries](/start/webhooks/event-types#delivery-statuses--retries) on the Events Reference. After the configured retry budget is exhausted the event is *not* retried indefinitely. It is left in the `FAILED` state. Inspect the failed events in the Sent Dashboard to diagnose endpoint problems and replay if needed. ## Handling Out-of-Order Events Events may arrive out of order. Use timestamps to ensure correct state: ```typescript async function handleMessageEvent(eventData: any) { const { timestamp } = eventData; const { message_id, message_status } = eventData.payload; // Get existing record const message = await db.messages.findById(message_id); // Only update if this event is newer if (new Date(timestamp) > new Date(message.lastUpdatedAt)) { await db.messages.update(message_id, { status: message_status, lastUpdatedAt: timestamp }); } } ``` ## Cleanup Strategy Clean up old event records periodically: ```typescript // Run daily async function cleanupOldEvents() { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); await db.webhookEvents.deleteMany({ where: { processedAt: { lt: thirtyDaysAgo }, status: 'completed' } }); } ``` ## Best Practices ### 1. Always Acknowledge Quickly ```typescript app.post('/webhooks/sent', async (req, res) => { // Acknowledge immediately res.sendStatus(200); // Process asynchronously — derive idempotency key from the payload itself await queue.add('process-webhook', { timestamp: req.headers['x-webhook-timestamp'], ...req.body, }); }); ``` ### 2. Handle Duplicate Events Gracefully ```typescript // Don't throw errors for duplicates if (existing) { console.log('Duplicate event, skipping'); return; // Not an error } ``` ### 3. Use Appropriate Deduplication Window ```typescript // Check last 7 days for duplicates const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const existing = await db.webhookEvents.findFirst({ where: { idempotencyKey: key, processedAt: { gte: oneWeekAgo } } }); ``` ### 4. Log for Debugging ```typescript logger.info('Processing webhook event', { idempotencyKey: key, eventType: field, messageId: eventData.payload?.message_id, status: eventData.payload?.message_status, timestamp: new Date().toISOString() }); ``` --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks.txt TITLE: Webhooks & Events Overview ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks.txt Receive real-time message delivery notifications, inbound messages, and template approval events via webhooks, with guides for setup, security, and retries # Webhooks & Events Overview Receive real-time notifications about message delivery status, template approvals, and platform events via HTTP callbacks. ## What are Webhooks? Webhooks are HTTP callbacks that Sent sends to your app when events occur. Instead of polling the API for updates, webhooks push events to your endpoint in real-time. Sent delivers two event types: `message` for delivery status updates and inbound messages, and `templates` for template lifecycle updates from Meta. Refer to the [Events Reference](/start/webhooks/event-types) for the envelope structure, full payloads, and filtering options. ### Webhook vs Polling | Approach | Latency | Efficiency | Complexity | |----------|---------|------------|------------| | **Webhooks** | Seconds | High (push) | Medium | | **Polling** | Minutes | Low (pull) | Low | ## Implementation Paths ### Path A: Simple Webhook (5 minutes) For basic message status tracking: **[Configure endpoint](/start/webhooks/getting-started)** - Set up your webhook URL **[Verify signatures](/start/webhooks/signature-verification)** - Ensure webhook authenticity **[Handle events](/start/webhooks/event-types)** - Process message status updates ### Path B: Production Webhook (30 minutes) For mission-critical applications: **Complete Path A** **[Implement idempotency](/start/webhooks/handling-retries)** - Handle duplicate events **[Queue-based processing](/start/webhooks/production-checklist)** - Scale with queues **[Local development & debugging](/start/webhooks/local-development)** - Test webhooks locally ## Webhook Guides **New to webhooks?** Start with [Webhook Setup](/start/webhooks/getting-started) to set up your first webhook endpoint. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/lifecycle.txt TITLE: Webhooks Lifecycle ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/lifecycle.txt How a webhook delivery moves from event generation through signing, retries, and the threshold that turns a failing webhook off, plus the events Sent emits # Webhooks Lifecycle This page describes what happens between the moment an event occurs inside Sent and the moment your endpoint receives (or stops receiving) a webhook delivery. Everything below is observable from your side, through the request itself or the delivery history in the Sent Dashboard. ## Delivery Lifecycle The diagram below shows the full lifecycle of a single webhook delivery: from event generation, through HTTP delivery to your endpoint, including retries and the conditions under which a webhook can be auto-disabled. The same flow drives both message events and template events; only the envelope contents differ. Filtering by `event_types` and `event_filters` is evaluated when the event is generated. Webhooks whose subscription doesn't match the event's sub-type don't receive a delivery attempt. Each retry uses exponential backoff between attempts. After your configured retry budget is exhausted, the delivery is marked `FAILED`, and too many consecutive failed delivery attempts auto-disable the webhook. You re-enable it from the Sent Dashboard. The retry schedule, retry budget, and auto-disable threshold are stated canonically in [Delivery Statuses & Retries](/start/webhooks/event-types#delivery-statuses--retries). ## End-to-end Message Event Flow The diagram below shows how a single outbound message moves through its sub-types and the points at which it can branch into `message.failed`. Inbound events are shown separately because they don't share the outbound state machine. Not every message travels the whole pipeline. A send held for the recipient's quiet hours surfaces `message.scheduled` while it waits, a routing policy such as a recipient opt-out finalizes it as `message.filtered`, and an account-level gate such as insufficient balance finalizes it as `message.blocked`. `FILTERED` and `BLOCKED` are policy decisions rather than delivery failures, so neither counts against your deliverability rate. See [Trust & Safety](/start/concepts/trust-and-safety) for the gates behind each one. Not every send produces every event. Channels and carriers vary in how many intermediate statuses they report. For example, some SMS carriers skip straight from `routed` to `delivered`. Treat the sub-types as a *partial order*: if you receive `message.delivered`, you can safely assume the message was queued, routed, and sent. ## Events at a Glance Sent emits two parent event categories, `message` and `templates`, and a handful of sub-types under each. The tables below summarise every webhook event, what triggers it, the direction it travels, and where in the lifecycle it can fail. ### Message events | Message Events | Direction | SMS | WhatsApp | RCS | What it means | Where it can go wrong | | :--- | :--- | :---: | :---: | :---: | :--- | :--- | | `QUEUED` | Outbound | Yes | Yes | Yes | Sent has accepted the request and is queueing it for routing. | Validation errors (bad number, missing template params) reject the request before this event is emitted. | | `ROUTED` | Outbound | Yes | Yes | Yes | The message has been assigned to a carrier or channel provider. | Routing can fail if there is no available route to the destination country/channel, which emits `message.failed`. | | `SENT` | Outbound | Yes | Yes | Yes | The carrier or provider has accepted the message for delivery. | Carrier rejects the payload (for example, content policy or a blocked number), which emits `message.failed`. | | `DELIVERED` | Outbound | Yes | Yes | Yes | The message reached the recipient's device. | Handset offline, number unreachable, or carrier delivery receipt missing, which emits `message.failed` (or stays at `sent` until the receipt times out). | | `READ` | Outbound | No | Yes | Yes | The recipient opened the message. Not emitted for SMS. | Recipient never opens the message, has read receipts turned off, or the channel doesn't support read receipts (SMS). | | `FAILED` | Outbound | Yes | Yes | Yes | Terminal failure: the message will not be delivered. The payload carries `message_status: FAILED` but no failure reason. | This *is* the failure event. To diagnose the cause, retrieve the message's activity history (`GET /v3/messages/{id}/activities`) or open the message in the Sent Dashboard. | | `SCHEDULED` | Outbound | Yes | Yes | Yes | The send is deferred until the recipient's quiet hours end. | The message is held, not lost; it re-enters the pipeline when the quiet-hours window closes. | | `FILTERED` | Outbound | Yes | Yes | Yes | A routing policy (for example, a recipient opt-out) suppressed the message before dispatch. | Terminal for that recipient: the message is not dispatched. | | `BLOCKED` | Outbound | Yes | Yes | Yes | An account-level gate (for example, insufficient balance or an unmet onboarding entitlement) stopped the message. | Terminal for that message; new sends stay gated until the account condition is resolved. | | `RECEIVED` | Inbound | Yes | Yes | Yes | A contact replied to one of your numbers (or, on RCS, tapped a quick-reply suggestion chip). | Inbound delivery to your endpoint can still fail at HTTP (see the retry/auto-disable lifecycle in the preceding section). | ### Template events Template events are emitted under the `templates` parent type as Meta updates a template's lifecycle. The most common statuses you'll receive: | Template Events | Direction | What it means | Where it can go wrong | | :--- | :--- | :--- | :--- | | `PENDING` | Outbound | Template has been submitted to Meta for review. | None | | `APPROVED` | Outbound | Meta approved the template. It is now usable for WhatsApp sends. | None | | `REJECTED` | Outbound | Meta rejected the template. The reason is in `payload.reason`. | Fix the template content or category, then re-submit. | | `CATEGORY_UPDATED` | Outbound | Meta moved the template to a different category (for example, MARKETING → UTILITY). The change description is in `payload.reason`. | None | Meta may also forward other lifecycle values verbatim (for example `PAUSED` or `DISABLED`); your handler should be tolerant of statuses outside the preceding list. ## What's Next - Subscribe to the events your app needs in the [Events Reference](/start/webhooks/event-types): full payload schemas and filtering rules. - Verify incoming requests against the signing secret described in [Signature Verification](/start/webhooks/signature-verification). - See [Handling Retries](/start/webhooks/handling-retries) for guidance on idempotency, response codes, and recovery. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/local-development.txt TITLE: Local Development & Debugging ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/local-development.txt Expose your local server to receive Sent webhooks through a tunnel, send test events, debug failed deliveries, and process events reliably with queues # Local Development & Debugging This guide shows you how to receive Sent webhooks on a local development server, send test events on demand, debug failed deliveries, and keep your handler fast with queue-based processing. **Prerequisites:** - A configured webhook endpoint (see [Webhook Setup](/start/webhooks/getting-started)) - A handler that verifies request signatures (see [Security](/start/webhooks/signature-verification)) For idempotency and duplicate-delivery handling, refer to [Handling Retries](/start/webhooks/handling-retries); this page links to those rules rather than restating them. ## Expose Your Local Server Webhooks are outbound HTTP requests initiated by Sent towards your app, so Sent must be able to reach your handler: - During development your app usually runs on `localhost`, which sits behind NAT or a firewall and has no public IP, so the Sent webhook delivery service cannot reach it directly. - Sent rejects URLs that resolve to private/loopback IP ranges (for example, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) at registration *and* delivery time, so a raw `http://localhost:5000/…` URL never works. - Both `http://` and `https://` schemes are accepted by the platform, but use HTTPS in production so the signed payload cannot be intercepted in transit. To receive webhooks locally, use a secure tunneling tool that exposes your local server to the internet through a public HTTPS URL. ### Create a tunnel with ngrok ```bash # Install ngrok (if not already installed) npm install -g @ngrok/ngrok # Start your local server npm run dev # Running on http://localhost:5000 # In another terminal, create tunnel ngrok http 5000 ``` This exposes your local server at a public URL: ``` https://abc123.ngrok.io -> http://localhost:5000 ``` Set your webhook's **Endpoint URL** to `https://abc123.ngrok.io/webhooks/sent`, either when [adding the webhook](/start/webhooks/getting-started) or by editing an existing one in the [Sent Dashboard](https://app.sent.dm/dashboard/webhooks). If your tunnel URL changes after a restart, update the webhook's endpoint URL to match. Deliveries to the stale URL show up as `FAILED` in the webhook's Events table. ### Alternative tunneling tools If ngrok does not fit your workflow, other options include: - **Cloudflare Tunnel**: persistent URLs and custom domains - **LocalTunnel**: installs from npm, no account required - **serveo.net**: SSH-based, nothing to install - **VS Code port forwarding**: built into the editor - **Tailscale Funnel**: exposes a server from an existing Tailscale network ## Send Test Events You do not need to send real messages to exercise your handler. Sent can deliver a sample payload for any event your webhook subscribes to: 1. In the [Sent Dashboard](https://app.sent.dm/dashboard/webhooks), open the webhook's actions menu and select **Test Webhook**. 2. The dialog lists the events the webhook subscribes to. Click **Test** next to an event, and Sent sends a sample payload for it to your endpoint. 3. The result appears immediately, and the delivery is recorded in the webhook's **Events** table like any other delivery. To trigger the same delivery from scripts or CI, call the [test endpoint](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint): ```bash curl -X POST "https://api.sent.dm/v3/webhooks/{webhook_id}/test" \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "message.sent"}' ``` The test endpoint is on the sensitive rate tier (10 requests per minute), so keep automated test-delivery loops slow. ## Debug Failed Deliveries Every delivery attempt is recorded. In the dashboard, open a webhook to inspect its **Events** table; each delivery shows: - the delivery status: `PENDING`, `RETRYING`, `DELIVERED`, or `FAILED` - the number of delivery attempts - the HTTP status code and the first portion of the response body your endpoint returned - the error message, plus processing start and completion timestamps The same data is available over the API via [`GET /v3/webhooks/{id}/events`](/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint). The retry schedule, retry budget, and auto-disable threshold are stated canonically in [Delivery Statuses & Retries](/start/webhooks/event-types#delivery-statuses--retries). When a local delivery fails, check these causes in order: 1. **Tunnel not running or URL stale**: restart the tunnel and confirm the webhook's endpoint URL matches its current public URL. 2. **Signature verification rejects the request**: verification must run on the raw request body; a body parsed and re-serialized by your framework produces a different signature. See [Security](/start/webhooks/signature-verification) for the scheme and [raw-body capture patterns](/build/webhook-receiver#why-the-raw-body-matters) per framework. 3. **Handler responds too slowly**: your endpoint must return `2xx` within the webhook's `timeout_seconds` (5–120 s, default 30 s). Move slow work out of the request path with [queue-based processing](#queue-based-processing). ## Queue-Based Processing Webhooks must return `2xx` quickly. Don't do heavy work inside the handler. Instead, push the event to a queue for background processing. The examples import `verifyWebhookSignature` and `idempotencyKey` from a shared `webhook-utils` module: implement them from the [signature scheme](/start/webhooks/signature-verification) and the [idempotency key rules](/start/webhooks/handling-retries#choosing-an-idempotency-key). **Acknowledge Immediately** - Verify signature, then return success status right away: ```javascript import { verifyWebhookSignature, idempotencyKey } from './webhook-utils.js'; // Use raw body parser app.use('/webhooks/sent', express.raw({ type: 'application/json' })); app.post("/webhooks/sent", (req, res) => { // Extract headers for signature verification const signature = req.get('x-webhook-signature'); const webhookId = req.get('x-webhook-id'); const timestamp = req.get('x-webhook-timestamp'); const webhookSecret = process.env.SENT_WEBHOOK_SECRET; const rawBody = req.body.toString(); if (!verifyWebhookSignature(rawBody, signature, webhookId, timestamp, webhookSecret)) { return res.status(401).send('Unauthorized'); } // Parse event const event = JSON.parse(rawBody); // Immediate acknowledgment res.sendStatus(200); // Push to queue for background processing with a payload-derived // idempotency key (see the Handling Retries page) queue.add("webhook-processing", { eventId: idempotencyKey(event), field: event.field, payload: event.payload, timestamp: event.timestamp, }); }); ``` ```python from celery import Celery from webhook_utils import verify_webhook_signature, idempotency_key celery_app = Celery('tasks', broker='redis://localhost:6379/0') @app.route('/webhooks/sent', methods=['POST']) def handle_webhook(): # Extract headers for signature verification signature = request.headers.get('x-webhook-signature') webhook_id = request.headers.get('x-webhook-id') timestamp = request.headers.get('x-webhook-timestamp') webhook_secret = os.environ.get('SENT_WEBHOOK_SECRET') raw_body = request.get_data() if not verify_webhook_signature(raw_body, signature, webhook_id, timestamp, webhook_secret): return 'Unauthorized', 401 # Parse event event = request.get_json() # Immediate acknowledgment # Push to queue for background processing with a payload-derived # idempotency key (see the Handling Retries page) process_webhook.delay( event_id=idempotency_key(event), field=event['field'], payload=event['payload'], timestamp=event['timestamp'] ) return '', 200 ``` ```go import "yourapp/webhook" // WebhookEvent maps the envelope: Field `json:"field"`, Event `json:"event"`, // Timestamp `json:"timestamp"`, Payload `json:"payload"` (json.RawMessage) func webhookHandler(w http.ResponseWriter, r *http.Request) { // Extract headers for signature verification signature := r.Header.Get("x-webhook-signature") webhookID := r.Header.Get("x-webhook-id") timestamp := r.Header.Get("x-webhook-timestamp") webhookSecret := os.Getenv("SENT_WEBHOOK_SECRET") body, _ := io.ReadAll(r.Body) if !webhook.VerifyWebhookSignature(body, signature, webhookID, timestamp, webhookSecret) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse event var event WebhookEvent json.Unmarshal(body, &event) // Immediate acknowledgment w.WriteHeader(http.StatusOK) // Push to queue for background processing with a payload-derived // idempotency key (idempotencyKey implements the Handling Retries rules) task := &Task{ EventID: idempotencyKey(event), Field: event.Field, Payload: event.Payload, Timestamp: event.Timestamp, } queue.Enqueue(task) } ``` **Process in Background** - Handle the business logic asynchronously: ```javascript // Worker process queue.process("webhook-processing", async (job) => { const { field, payload, timestamp } = job.data; try { if (field === 'message') { await handleMessageEvent(payload); } else if (field === 'templates') { await handleTemplateEvent(payload); } console.log(`Successfully processed ${field} event`); } catch (error) { console.error(`Failed to process ${field} event:`, error); throw error; // Will trigger retry based on queue configuration } }); async function handleMessageEvent(payload) { const { message_id, message_status } = payload; if (message_status === 'DELIVERED') { // Update order status, send notification, etc. await updateOrderStatus(message_id, 'delivered'); } else if (message_status === 'FAILED') { await notifyDeliveryFailure(message_id); } } async function handleTemplateEvent(payload) { const { template_id, status } = payload; if (status === 'APPROVED') { await enableTemplate(template_id); } } ``` ```python @celery_app.task(bind=True, max_retries=3) def process_webhook(self, event_id, field, payload, timestamp): try: if field == 'message': handle_message_event(payload) elif field == 'templates': handle_template_event(payload) print(f"Successfully processed {field} event") except Exception as error: print(f"Failed to process {field} event: {error}") raise self.retry(exc=error, countdown=60) def handle_message_event(payload): message_id = payload['message_id'] message_status = payload['message_status'] if message_status == 'DELIVERED': # Update order status, send notification, etc. update_order_status(message_id, 'delivered') elif message_status == 'FAILED': notify_delivery_failure(message_id) def handle_template_event(payload): template_id = payload['template_id'] template_name = payload['template_name'] status = payload['status'] if status == 'APPROVED': enable_template(template_id) ``` ```go // Worker process func processWebhookWorker(queue *Queue) { for task := range queue.Tasks { field := task.Field event := task.Event var err error switch field { case "message": err = handleMessageEvent(event) case "templates": err = handleTemplateEvent(event) } if err != nil { log.Printf("Failed to process %s event: %v", field, err) // Will trigger retry based on queue configuration queue.Retry(task) } else { log.Printf("Successfully processed %s event", field) } } } func handleMessageEvent(event WebhookEvent) error { var payload MessagePayload if err := json.Unmarshal(event.Payload, &payload); err != nil { return err } if payload.MessageStatus == "DELIVERED" { // Update order status, send notification, etc. return updateOrderStatus(payload.MessageID, "delivered") } else if payload.MessageStatus == "FAILED" { return notifyDeliveryFailure(payload.MessageID) } return nil } func handleTemplateEvent(event WebhookEvent) error { var payload TemplatePayload if err := json.Unmarshal(event.Payload, &payload); err != nil { return err } return syncTemplate(payload.TemplateID) } ``` ### Queue options Pick whatever queue your stack already runs: for example, BullMQ for Node.js, Celery for Python, or a managed service such as Amazon SQS. Example setup with retry configuration: ```javascript import { Queue, Worker } from 'bullmq'; import { idempotencyKey } from './webhook-utils.js'; const webhookQueue = new Queue("webhook-processing", { connection: { host: "localhost", port: 6379 }, }); // Add to queue (use raw body parser to preserve the raw bytes for signature verification) app.use('/webhooks/sent', express.raw({ type: 'application/json' })); app.post("/webhooks/sent", async (req, res) => { res.sendStatus(200); const event = JSON.parse(req.body.toString()); await webhookQueue.add("process-event", { eventId: idempotencyKey(event), ...event, }, { attempts: 3, backoff: { type: "exponential", delay: 5000, }, }); }); // Process from queue const worker = new Worker( "webhook-processing", async (job) => { const eventData = job.data; await processWebhookEvent(eventData); }, { connection: { host: "localhost", port: 6379 } } ); ``` ```python from celery import Celery from kombu import Exchange, Queue from webhook_utils import idempotency_key # Configure Celery celery_app = Celery('webhook_processor', broker='redis://localhost:6379/0') celery_app.conf.task_routes = { 'process_webhook_event': {'queue': 'webhook-processing'} } celery_app.conf.task_queues = ( Queue('webhook-processing', Exchange('webhook-processing'), routing_key='webhook'), ) # Add to queue @app.route('/webhooks/sent', methods=['POST']) def handle_webhook(): event_data = request.get_json() # Queue with retry configuration and a payload-derived idempotency key process_webhook_event.apply_async( kwargs={ 'event_id': idempotency_key(event_data), 'field': event_data['field'], 'payload': event_data['payload'], 'timestamp': event_data['timestamp'], }, retry=True, retry_policy={ 'max_retries': 3, 'interval_start': 5, 'interval_step': 5, 'interval_max': 15, } ) return '', 200 # Process from queue @celery_app.task(bind=True, max_retries=3) def process_webhook_event(self, event_data): try: # Process webhook event handle_event(event_data) except Exception as exc: raise self.retry(exc=exc, countdown=5) ``` ```go import ( "github.com/gomodule/redigo/redis" "encoding/json" ) // Queue configuration type WebhookQueue struct { pool *redis.Pool } func NewWebhookQueue() *WebhookQueue { return &WebhookQueue{ pool: &redis.Pool{ MaxIdle: 10, Dial: func() (redis.Conn, error) { return redis.Dial("tcp", "localhost:6379") }, }, } } // Add to queue type QueuedEvent struct { EventID string `json:"event_id"` // payload-derived idempotency key (see the Handling Retries page) WebhookEvent } func webhookHandler(w http.ResponseWriter, r *http.Request) { var event WebhookEvent json.NewDecoder(r.Body).Decode(&event) w.WriteHeader(http.StatusOK) // Add to Redis queue with a payload-derived idempotency key conn := queue.pool.Get() defer conn.Close() queued := QueuedEvent{EventID: idempotencyKey(event), WebhookEvent: event} data, _ := json.Marshal(queued) conn.Do("LPUSH", "webhook-processing", data) } // Process from queue func (q *WebhookQueue) ProcessWorker() { conn := q.pool.Get() defer conn.Close() for { reply, err := redis.ByteSlices(conn.Do("BRPOP", "webhook-processing", 0)) if err != nil { log.Printf("Queue error: %v", err) continue } var event WebhookEvent if err := json.Unmarshal(reply[1], &event); err != nil { log.Printf("Unmarshal error: %v", err) continue } if err := processWebhookEvent(event); err != nil { log.Printf("Processing error: %v", err) // Re-queue with retry logic conn.Do("LPUSH", "webhook-processing:retry", reply[1]) } } } ``` ## Related Pages - [Security](/start/webhooks/signature-verification): the signature scheme and verification steps - [Handling Retries](/start/webhooks/handling-retries): idempotency keys, deduplication, and out-of-order events - [Events Reference](/start/webhooks/event-types): payloads, delivery statuses, and the retry schedule - [Production Checklist](/start/webhooks/production-checklist): what to confirm before going live --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/production-checklist.txt TITLE: Production Checklist ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/production-checklist.txt Pre-launch checklist for Sent webhook handlers: HTTPS and signature verification, idempotency, monitoring, error handling, scaling, and a runbook template. # Production Checklist Before deploying your webhook handler to production, ensure you've covered all these critical items. ## Security ### ✅ HTTPS Only - [ ] Webhook URL uses `https://` - [ ] SSL certificate is valid - [ ] HTTP requests are redirected to HTTPS ### ✅ Signature Verification - [ ] Implement HMAC signature verification - [ ] Use constant-time comparison - [ ] Reject requests with invalid signatures - [ ] Rotate secrets if compromised ```typescript if (!verifySignature(req)) { return res.status(401).send('Invalid signature'); } ``` ### ✅ Timestamp Validation - [ ] Reject old requests (> 5 minutes) - [ ] Prevent replay attacks ```typescript const timestamp = req.headers['x-webhook-timestamp']; const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) { return res.status(401).send('Timestamp too old'); } ``` ## Reliability ### ✅ Quick Response - [ ] Return 2xx well within the webhook's configured timeout (`timeout_seconds`, 5–120s, default 30s) - [ ] Process asynchronously using queues - [ ] Handle errors without failing the request ```typescript app.post('/webhooks/sent', (req, res) => { // Acknowledge immediately res.sendStatus(200); // Process in background — idempotency key is derived from the payload (see Handling Retries) queue.add('webhook', { timestamp: req.headers['x-webhook-timestamp'], ...req.body, }); }); ``` ### ✅ Idempotency - [ ] Derive an idempotency key from the payload (e.g. `message_id` + `message_status`). `X-Webhook-ID` is the webhook configuration UUID, *not* a per-event ID - [ ] Store processed idempotency keys - [ ] Handle out-of-order events - [ ] Use database transactions See [Handling Retries](/start/webhooks/handling-retries) for implementation. ### ✅ Retry Handling - [ ] Expect duplicate and repeated deliveries. Sent delivers events at least once (see the [retry schedule](/start/webhooks/event-types#delivery-statuses--retries)) - [ ] Make processing idempotent so a redelivered event causes no duplicate side effects - [ ] Implement exponential backoff for your own downstream retries (queue re-processing, database writes) ## Monitoring ### ✅ Logging - [ ] Log all webhook events - [ ] Include the payload-derived idempotency key, event type, and timestamp - [ ] Use structured logging (JSON) - [ ] Set appropriate log levels ```typescript logger.info('Webhook received', { webhookId: req.headers['x-webhook-id'], // webhook configuration UUID (not per-event) eventType: req.body.field, event: req.body.event, // granular sub-type, e.g. message.delivered timestamp: new Date().toISOString() }); ``` ### ✅ Alerting - [ ] Alert on high error rates - [ ] Monitor webhook delivery failures - [ ] Set up PagerDuty/Opsgenie for critical issues ```typescript if (errorRate > 0.05) { // 5% error rate await alertTeam('High webhook error rate', { errorRate }); } ``` ### ✅ Metrics Track: - [ ] Webhook events received (counter) - [ ] Processing duration (histogram) - [ ] Failed events (counter) - [ ] Queue depth (gauge) ## Error Handling ### ✅ Graceful Degradation - [ ] Handle partial failures - [ ] Continue processing other events - [ ] Don't fail the entire batch ```typescript for (const event of events) { try { await processEvent(event); } catch (error) { logger.error('Event processing failed', { event, error }); // Continue with next event } } ``` ### ✅ Dead Letter Queue - [ ] Failed events go to DLQ - [ ] Manual review process - [ ] Retry from DLQ capability ## Scalability ### ✅ Queue-Based Processing - [ ] Use Redis/RabbitMQ/SQS - [ ] Multiple worker processes - [ ] Horizontal scaling ready ```typescript const worker = new Worker('webhooks', processor, { connection: redisConnection, concurrency: 10 }); ``` ### ✅ Rate Limiting - [ ] Protect downstream services - [ ] Implement backoff - [ ] Queue events if overloaded ## Testing ### ✅ Local Testing - [ ] Test with ngrok/localtunnel - [ ] Verify signature verification - [ ] Test retry handling See [Local Development](/start/webhooks/local-development). ### ✅ Load Testing - [ ] Test with high event volume - [ ] Verify queue doesn't overflow - [ ] Check response times ### ✅ Failure Testing - [ ] Test with invalid signatures - [ ] Test with malformed payloads - [ ] Test database failure scenarios ## Infrastructure ### ✅ High Availability - [ ] Multiple server instances - [ ] Load balancer configured - [ ] Database replicas - [ ] Redis cluster (if using) ### ✅ Backups - [ ] Database backups - [ ] Configuration backups - [ ] Tested restore procedures ## Pre-Launch Verification Run through this final checklist: - [ ] Webhook URL returns 200 quickly (< 1 s response time) - [ ] Signature verification rejects invalid signatures - [ ] Duplicate events are handled correctly - [ ] Logs are flowing to central logging - [ ] Alerts are configured and tested - [ ] Queue workers are running - [ ] Database connections are pooled - [ ] SSL certificate is valid - [ ] Rate limits are configured - [ ] Runbook is documented ## Dashboard Configuration Verify in [Sent Dashboard](https://app.sent.dm/dashboard/webhooks): - [ ] Webhook URL is correct - [ ] Event types are selected - [ ] Secret is secure - [ ] Test webhook delivery succeeds ## Runbook Template Create a runbook for your team: ```markdown # Webhook Runbook ## High Error Rate Alert 1. Check webhook logs in dashboard 2. Verify endpoint is responding 3. Check application logs 4. If needed, temporarily disable webhook ## Missed Events 1. Check webhook delivery logs 2. Verify idempotency handling 3. Manual replay if needed ## Secret Rotation 1. Generate new secret in dashboard 2. Update application config 3. Deploy new config 4. Test webhook 5. Remove old secret ``` **Ready to go live?** After completing this checklist, enable your webhook in the Sent Dashboard and monitor closely for the first 24 hours. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/receiving-inbound-messages.txt TITLE: Receiving Inbound Messages via the message.received Webhook ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/receiving-inbound-messages.txt How to receive contact replies in real time with the message.received webhook, acknowledge and deduplicate deliveries, and skip compliance keywords. # Receiving Inbound Messages via the message.received Webhook This guide shows you how to process contact replies in real time: identify `message.received` events, acknowledge and deduplicate deliveries, store the inbound message, and trigger a follow-up flow for non-keyword replies. **Prerequisites:** - A webhook endpoint registered in the Sent Dashboard and subscribed to `message` events; see [Webhook Setup](/start/webhooks/getting-started) - A channel that can receive inbound messages. Alphanumeric sender IDs and SMPP providers are send-only; refer to the [channel support tables](/reference/two-way-messaging#channel-support) ## Build the Inbound Handler ### Identify inbound events Sent fires `message.received` for every inbound message, free-text replies and compliance keywords alike. Filter on the envelope's `field` and `event` values: ```json { "field": "message", "event": "message.received", "timestamp": "2025-01-15T08:35:00Z", "payload": { "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "updated_at": "2025-01-15T08:34:58Z", "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "inbound_number": "+1234567890", "outbound_number": "+1987654321", "text": "Yes, tell me more", "channel": "sms", "received_at": "2025-01-15T08:34:58Z" } } ``` `inbound_number` is the contact's phone number; `outbound_number` is your provisioned number that received the message. The [Events Reference](/start/webhooks/event-types) documents every payload field. ### Acknowledge, then deduplicate Respond with a `2xx` status before doing any processing; slow handlers cause redeliveries, and Sent retries failed deliveries anyway (see [Handling Retries](/start/webhooks/handling-retries)). If your endpoint verifies signatures, verify before acknowledging; see [Signature Verification](/start/webhooks/signature-verification). Use `payload.message_id` as your idempotency key. The same event can be delivered more than once, so deduplicate on `message_id` before processing to avoid double inserts or duplicate follow-up flows. ### Store the message and trigger follow-ups Store every inbound for your CRM or conversation view, then branch: if the text is a compliance keyword, do nothing, because Sent's consent engine already handles it. If it is a real reply, trigger your follow-up flow. ```typescript import express from 'express'; const app = express(); app.use(express.json()); app.post('/webhooks/sent', async (req, res) => { res.sendStatus(200); // Always acknowledge quickly const { field, event, payload } = req.body; if (field === 'message' && event === 'message.received') { const { message_id, inbound_number, outbound_number, text, channel, received_at } = payload; // Store inbound for your CRM / conversation view await db.inbound.insert({ messageId: message_id, from: inbound_number, to: outbound_number, text, channel, receivedAt: received_at }); // Optionally trigger a follow-up flow const COMPLIANCE_KEYWORDS = ['stop', 'cancel', 'unsubscribe', 'quit', 'end', 'start', 'unstop', 'subscribe', 'help', 'info']; if (text && !COMPLIANCE_KEYWORDS.includes(text.toLowerCase().trim())) { await triggerResponseFlow({ from: inbound_number, channel }); } } }); ``` ```python from flask import Flask, request app = Flask(__name__) @app.route('/webhooks/sent', methods=['POST']) def handle_webhook(): data = request.json if data['field'] == 'message' and data.get('event') == 'message.received': p = data['payload'] db.inbound.insert( message_id=p['message_id'], from_number=p['inbound_number'], to=p['outbound_number'], text=p.get('text'), channel=p['channel'], received_at=p['received_at'] ) compliance_keywords = {'STOP', 'CANCEL', 'UNSUBSCRIBE', 'QUIT', 'END', 'START', 'UNSTOP', 'SUBSCRIBE', 'HELP', 'INFO'} text = p.get('text') if text and text.strip().upper() not in compliance_keywords: trigger_response_flow(from_number=p['inbound_number'], channel=p['channel']) return '', 200 ``` ```go package main import ( "encoding/json" "net/http" "strings" ) type Payload struct { MessageID string `json:"message_id"` InboundNumber string `json:"inbound_number"` OutboundNumber string `json:"outbound_number"` Text string `json:"text"` Channel string `json:"channel"` ReceivedAt string `json:"received_at"` } type WebhookEvent struct { Field string `json:"field"` Event string `json:"event"` Payload Payload `json:"payload"` } func webhookHandler(w http.ResponseWriter, r *http.Request) { var event WebhookEvent if err := json.NewDecoder(r.Body).Decode(&event); err != nil { w.WriteHeader(http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) if event.Field == "message" && event.Event == "message.received" { p := event.Payload db.Inbound.Insert(p.MessageID, p.InboundNumber, p.OutboundNumber, p.Text, p.Channel, p.ReceivedAt) complianceKeywords := map[string]bool{ "STOP": true, "CANCEL": true, "UNSUBSCRIBE": true, "QUIT": true, "END": true, "START": true, "UNSTOP": true, "SUBSCRIBE": true, "HELP": true, "INFO": true, } if p.Text != "" && !complianceKeywords[strings.ToUpper(strings.TrimSpace(p.Text))] { triggerResponseFlow(p.InboundNumber, p.Channel) } } } ``` ```csharp [HttpPost("/webhooks/sent")] public async Task HandleWebhook([FromBody] JsonElement body) { var field = body.GetProperty("field").GetString(); var evt = body.GetProperty("event").GetString(); if (field == "message" && evt == "message.received") { var payload = body.GetProperty("payload"); var messageId = payload.GetProperty("message_id").GetString(); var from = payload.GetProperty("inbound_number").GetString(); var to = payload.GetProperty("outbound_number").GetString(); var text = payload.TryGetProperty("text", out var t) ? t.GetString() : null; var channel = payload.GetProperty("channel").GetString(); var receivedAt = payload.GetProperty("received_at").GetString(); await _db.Inbound.InsertAsync(new InboundMessage { MessageId = messageId, From = from, To = to, Text = text, Channel = channel, ReceivedAt = receivedAt }); var complianceKeywords = new HashSet { "STOP", "CANCEL", "UNSUBSCRIBE", "QUIT", "END", "START", "UNSTOP", "SUBSCRIBE", "HELP", "INFO" }; if (text != null && !complianceKeywords.Contains(text.Trim().ToUpper())) { await _flowService.TriggerResponseFlowAsync(from, channel); } } return Ok(); } ``` If you configured custom keywords in the dashboard, add them to the keyword set. Match them the way Sent does (the entire trimmed text equals the keyword, case-insensitive); see the [matching rules](/reference/two-way-messaging#matching-rules). ### Verify the handler From a test phone, text a free-form reply to your provisioned number: a `message.received` event arrives, your store gains a row, and your follow-up flow triggers. Then text `STOP`: the event still arrives, but your follow-up flow must not trigger; Sent records the opt-out on its own. Delivery attempts, HTTP status codes, and response bodies for each event are visible per webhook in the Sent Dashboard. ## Troubleshooting | Symptom | Likely cause | Fix | |---------|--------------|-----| | No `message.received` events arrive | The sender is an alphanumeric sender ID or the route uses an SMPP provider; both are send-only | Use a long code or short code on an MO-capable provider; see [SMS Provider Support](/reference/two-way-messaging#sms-provider-support) | | Events stopped arriving on one number | The number is no longer provisioned or assigned, so inbounds cannot be matched to your account and are dropped | Verify the provider and number under your channel settings | | The same reply is processed twice | Redelivered event | Deduplicate on `payload.message_id` before processing | | Follow-up flow fires for `STOP` | Keyword filter does not match Sent's rule | Compare the trimmed, case-normalized text against the full keyword list, defaults plus custom | ## Related pages - [Two-Way Conversations](/start/guides/two-way-conversations): how inbound matching, storage, and keyword handling work - [Events Reference](/start/webhooks/event-types): the full `message.received` payload, per channel - [Two-Way Messaging Reference](/reference/two-way-messaging): keyword list, matching rules, and channel support - [Handling Opt-Outs and Consent](/start/guides/opt-out-and-consent): mirror keyword opt-outs into your own database ================================================================================ SOURCE: https://docs.sent.dm/llms/start/webhooks/signature-verification.txt TITLE: Security ================================================================================ URL: https://docs.sent.dm/llms/start/webhooks/signature-verification.txt Verify webhook authenticity and implement security best practices for Sent webhooks # Webhook Security Webhook security is critical to ensure that requests to your endpoint are actually coming from Sent and haven't been tampered with. This page covers how to verify webhook authenticity and implement security best practices. ## Why Webhook Security Matters Without proper verification, attackers could: * Send fake webhook requests to trigger unwanted actions * Modify payload data to manipulate your app * Overload your webhook endpoint with requests ## How Sent Signs Webhooks Sent signs every webhook request with an **HMAC-SHA256** signature computed from the webhook's signing secret. This creates a unique signature for each request that proves: 1. The request came from Sent 2. The payload hasn't been modified ## Finding Your Secret Key Each webhook endpoint has a unique secret that's used to sign all requests to that endpoint. To access your webhook secret, you can click your desired webhook endpoint in your [Sent Dashboard](https://app.sent.dm/dashboard/webhooks) and click the **Eye Icon** button to view the secret, or you can click the **Copy** button to copy the secret to your clipboard.
Keep your webhook secret secure and never expose it publicly. If you believe your secret has been compromised, regenerate it immediately from your dashboard settings. ## Signature Verification ### How Signatures Work Each webhook request includes several headers for verification: | Header | Description | Example | | :--- | :--- | :--- | | `x-webhook-signature` | HMAC-SHA256 signature | `v1,abc123...` (base64) | | `x-webhook-id` | Unique webhook endpoint ID | `550e8400-e29b-41d4-a716-446655440000` | | `x-webhook-timestamp` | Unix timestamp (seconds) | `1705334531` | | `x-webhook-event-type` | Fully qualified event type | `message.delivered`, `message.received`, or `templates` | The signature format is: ``` x-webhook-signature: v1,{base64_encoded_signature} ``` The signature is computed as follows: 1. Strip the `whsec_` prefix from your signing secret 2. Base64-decode the remaining secret to get raw key bytes 3. Concatenate: `{webhookId}.{timestamp}.{payload}` (dot-separated) 4. Compute HMAC-SHA256 using the raw key bytes 5. Base64-encode the result 6. Prefix with `v1,` ### Step-by-Step Verification **Extract headers** - Get `x-webhook-signature`, `x-webhook-id`, and `x-webhook-timestamp` **Prepare your secret** - Strip the `whsec_` prefix and Base64-decode to get raw bytes **Build signed content** - Concatenate: `{webhookId}.{timestamp}.{rawBody}` **Compute HMAC-SHA256** using the decoded secret key bytes **Compare signatures** using a timing-safe comparison function **Only process** the webhook if signatures match You should also verify that the timestamp is recent (within 5 minutes) to prevent replay attacks. ## Security Best Practices ### Always Validate Signatures Never trust a webhook request without signature verification: ```javascript // ❌ BAD - No verification app.post('/webhook', (req, res) => { processWebhook(req.body); // Dangerous! res.status(200).send('OK'); }); // ✅ GOOD - Verified first app.post('/webhook', (req, res) => { if (!verifySignature(req)) { return res.status(401).send('Unauthorized'); } processWebhook(req.body); res.status(200).send('OK'); }); ``` The example calls a `verifySignature` helper that implements the six steps in [Step-by-Step Verification](#step-by-step-verification). Copy a complete implementation for your language from [the verifier in seven languages](/build/signature-verification#the-verifier-in-seven-languages), and always run it against the raw request body. A body parsed and re-serialized by your framework produces a different signature. ### Use Timing-Safe Comparisons Standard string comparison can leak timing information. Use dedicated functions: * **Node.js**: `crypto.timingSafeEqual()` * **Python**: `hmac.compare_digest()` * **Go**: `subtle.ConstantTimeCompare()` * **Other languages**: Look for "constant-time" or "timing-safe" comparison functions Regular string comparison (`==`) can exit early when it finds a mismatch, potentially leaking information about the correct signature through timing analysis. Timing-safe functions always compare the entire string, preventing this attack vector. ### Prefer HTTPS Sent accepts both `http://` and `https://` endpoints at registration time, but you should always use HTTPS in production: ```json { "url": "https://your-app.com/webhook" // ✅ Recommended } ``` ```json { "url": "http://your-app.com/webhook" // ⚠️ Not recommended — payload travels in clear text } ``` While the platform allows http URLs (useful for some tunnels and internal testing), all production traffic should be HTTPS so the signed payload cannot be intercepted or modified in transit. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/why-sent/comparison.txt TITLE: Compare Your Options ================================================================================ URL: https://docs.sent.dm/llms/start/why-sent/comparison.txt Compare Sent with direct provider integration and building in-house: setup time, maintenance, cost structure, migration paths, and a decision flowchart. # Compare Your Options Choosing the right messaging infrastructure is a critical decision that impacts your engineering resources, time to market, and ongoing operational costs. This page compares Sent with the main alternatives to help you make an informed choice. ## The Options ## Comparison Matrix | Factor | Direct Providers | Build In-House | Sent | |--------|-----------------|----------------|------| | **Initial Setup** | 2-4 weeks per provider | 3-6 months | [Light onboarding](/start/try-sent) / 1-2 days (production) | | **Multi-Channel** | Manual integration each | Build yourself | Built-in | | **Automatic Fallback** | Custom logic required | Build yourself | Automatic | | **Cost Optimization** | Manual rate comparison | Build analytics | Real-time routing | | **Compliance** | Self-managed | Self-managed | Built-in | | **Maintenance** | High (API changes) | Very high | Minimal | | **Time to Market** | Slow | Very slow | Fast | ## Detailed Comparison ### Sent vs Direct Provider Integration **Direct Integration Approach:** ```javascript // SMS provider if (useSMS) { await smsProvider.messages.create({ body: message, from: smsNumber, to: phoneNumber }); } // WhatsApp Business API for WhatsApp if (useWhatsApp && await checkWhatsApp(phoneNumber)) { await sendWhatsAppMessage({ to: phoneNumber, template: whatsappTemplate }); } // Handle failures, retries, fallbacks manually ``` **With Sent:** ```javascript // One call, intelligent routing await sent.messages.send({ to: [phoneNumber], template: { id: templateId } // Automatic channel selection and fallback }); ``` | Aspect | Direct Providers | Sent | |--------|-----------------|------| | **Code Complexity** | High - multiple SDKs, formats, error handling | Low - single SDK, unified format | | **Channel Management** | You manage SMS + WhatsApp + RCS separately | Unified: one API for SMS, WhatsApp, and RCS | | **Fallback Logic** | Build and maintain yourself | Automatic with intelligent routing | | **Provider Relationships** | Manage contracts with each | Single relationship | | **Rate Limiting** | Handle per-provider | Managed automatically | | **Cost Optimization** | Manual rate shopping | Real-time intelligent routing | **When to choose Direct Providers:** - Single channel (SMS only) is sufficient - Very low volume (< 1,000 messages/month) - Already deeply integrated with a specific provider - Need provider-specific features not abstracted by Sent ### Sent vs Building In-House **Building In-House Requires:** 1. **Integration Layer** - Multiple provider SDKs - Unified API design - Authentication management - Error code normalization 2. **Routing Intelligence** - Channel availability detection - Cost comparison engine - Delivery success tracking - Machine learning for optimization 3. **Operational Complexity** - Provider health monitoring - Failover automation - Rate limit management - API change tracking 4. **Compliance Infrastructure** - GDPR compliance tools - TCPA compliance - Country-specific regulations - Opt-out management **Timeline Comparison:** The timelines below are illustrative estimates for a typical product team adding multi-channel messaging; actual effort depends on scope, channels, and compliance requirements. | Component | Build In-House | With Sent | |-----------|---------------|-----------| | Initial MVP | 3-6 months | 1-2 days | | Multi-provider support | +2-3 months | Included | | Intelligent routing | +3-6 months | Included | | Compliance framework | +2-4 months | Included | | **Total Time** | **8-18 months** | **1-2 days** | **Hidden Costs of Building In-House:** - Engineering headcount: Messaging becomes a permanent line on a dedicated team's roadmap - Ongoing maintenance: Expect a meaningful share of the initial build effort again every year - Opportunity cost: Features delayed while building infrastructure - Technical debt: Legacy code as messaging evolves ### When to Choose Each Option #### Choose Direct Provider Integration If: - SMS-only requirement - Low message volume - Simple use case (OTP only) - Already integrated with provider - Need specific provider features #### Build In-House If: - Have dedicated messaging team - Very specific routing requirements - Regulatory requirements mandate it - Messaging is core competitive advantage - Have 12+ months to invest #### Choose Sent If: - Want to try before committing ([light onboarding](/start/try-sent) needs only a verified email and phone number) - Multi-channel needs (SMS + WhatsApp + RCS) - Want RCS branded messaging (verified sender, suggestion chips, read receipts) without building separate integrations - Want automatic optimization - Need to move fast - Don't want infrastructure maintenance - Growing message volume - International expansion plans ## Migration Scenarios ### Migrating from Direct Providers Teams migrating from a direct provider typically start with non-critical messages such as notifications, run Sent in parallel with the existing integration to compare delivery rates and costs, then move critical flows (OTP, alerts) before decommissioning the old provider. **Timeline:** 2-4 weeks for a complete migration ### Migrating from an In-House System The common pattern is an audit of current messaging flows, a mapping of in-house features to Sent capabilities, then a parallel run in which traffic gradually shifts to Sent until the in-house infrastructure can be retired. **Timeline:** 4-8 weeks depending on complexity ## Cost Analysis Actual costs depend on your message volume, destination regions, channel mix, and engineering rates, so treat any specific figure as an illustration rather than a quote. What holds across scenarios is the shape of each option's cost structure: | Cost Component | Direct Providers | Build In-House | Sent | |----------------|-----------------|----------------|------| | Message costs | Provider list rates | Provider rates you negotiate | Platform rates with routing optimization | | Engineering | Ongoing integration work per provider | A dedicated team, permanently | Initial integration only | | Infrastructure | Your own queues and monitoring | The full stack, self-hosted | Included | | Maintenance | Tracking each provider's API changes | The entire system | Included | Per-message rates are broadly similar across all three options, so the comparison usually comes down to engineering time. Once team cost is counted, a shared platform is usually the cheapest option: the engineering is amortized across every customer instead of carried by yours alone. ## Feature Comparison | Feature | Direct Providers | Build In-House | Sent | |---------|-----------------|----------------|------| | **Unified API** | No | Yes (custom) | Yes | | **Multi-channel** | No | Build yourself | Yes | | **Auto-fallback** | No | Build yourself | Yes | | **Smart routing** | No | Build yourself | Yes | | **Real-time analytics** | Partial | Build yourself | Yes | | **Webhook management** | Basic | Build yourself | Yes | | **Template system** | No | Build yourself | Yes | | **Compliance tools** | No | Build yourself | Yes | | **Contact intelligence** | No | Build yourself | Yes | | **SDK availability** | Per-provider | Build yourself | 7 languages | | **SLA guarantees** | Per-provider | Self-managed | Yes | ## Decision Flowchart Not ready to work through the decision? You can [try Sent](/start/try-sent) with light onboarding first, before committing to any option. ## Next Steps **Ready to explore Sent?** - [Try Sent: Quick Start](/start/try-sent) - send your first message with light onboarding, no code required - [Quickstart Guide](/start/quickstart) - Full setup guide for production - [Platform Overview](/start/concepts/platform-overview) - Understand the architecture - [What Sent Solves](/start/why-sent/use-cases) - Check whether your scenario fits **Still evaluating?** - [Contact Sales](mailto:sales@sent.dm) for a personalized consultation - Review the [API Reference](/reference/api) - Check out the [SDKs](/sdks) --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/why-sent.txt TITLE: Built for Scale ================================================================================ URL: https://docs.sent.dm/llms/start/why-sent.txt Why messaging became infrastructure: the forces that outgrow custom code and how shared platforms turn aggregate traffic into better delivery for everyone. # Built for Scale Messaging infrastructure has followed the same curve as other parts of the stack. At first, you hardcode something simple, and it works. But over time, complexity builds until you need dedicated infrastructure. Just as teams moved from running bare-metal servers to using AWS, or from building their own payment logic to plugging in Stripe, messaging has reached the point where custom code can't keep up. This page explains why that shift happened and why shared infrastructure ends up ahead of anything a single team can build. For the decision itself, whether to build in-house, integrate providers directly, or use Sent, see [Compare Your Options](/start/why-sent/comparison). ## The Infrastructure Pattern Every app starts the same way: call an API to send an SMS. Easy. The moment you scale or expand globally, four forces start compounding: - **Channel Sprawl**: SMS isn't enough. Users fragment across WhatsApp, RCS, and whatever arrives next, and the mix shifts by country and demographic. - **Regulatory Overhead**: Every region and channel has its own compliance rules, approvals, and restrictions. Miss one detail and messages don't deliver. - **Cost Pressure**: At scale, every message costs money. Optimizing delivery requires real knowledge of provider pricing quirks, regional routes, and performance. - **Reliability Expectations**: Verification codes, confirmations, and alerts can't fail. That means failover logic, redundancy, and monitoring, none of which can be improvised. None of these forces is a product feature. They are properties of messaging itself, which is why they eventually outgrow app code, no matter how well that code is written. ## Why Shared Infrastructure Wins The case for a shared platform is structural rather than a set of measured claims: a platform that carries traffic for many customers can learn things no single app can. - **Learning from Aggregate Traffic**: Routing and delivery logic improve with every message the platform carries. One customer's carrier edge case becomes a routing rule that protects everyone else. - **Cross-Region Visibility**: A platform observes carrier and channel performance across every region its customers send to. A single app only ever sees its own traffic, usually too small a sample to optimize against. - **Compliance at the Platform Layer**: Regulations change per country and per channel. When the platform absorbs those updates, no individual team needs to track them. - **Collective Weight with Providers**: A platform negotiates rates, support, and channel access on behalf of its entire customer base, a stronger position than any single app team holds. The same reasoning explains why in-house builds tend to plateau: telecom and compliance are not core skills for most product teams, and the data needed to keep improving is not available to a single tenant. ## Platform-Level Advantages Once messaging is treated as infrastructure, improvements land in a different place than they do with an in-app integration: - **Shared Wins**: An optimization discovered for one customer applies to the whole platform. - **Fast Reaction**: When a carrier degrades or a regulation shifts, the fix ships at the infrastructure layer, without waiting on your next deploy. - **Future Channels**: New channels and capabilities show up in the platform, not in your backlog. - **Aligned Incentives**: A per-message provider earns more when you send more. An infrastructure platform competes on delivery quality and total cost, so its incentive is to make each message cheaper and more likely to arrive. ## Where This Leaves Your Team Running your own messaging stack is permanent work: provider API changes, shifting compliance rules, performance tuning, and testing across channels and failure cases never stop. Treating messaging as bought infrastructure converts that recurring cost into a dependency, and keeps your engineers on the product work that differentiates you. Whether that trade is right for your team depends on volume, channels, and how central messaging is to your product. [What Sent Solves](/start/why-sent/use-cases) describes the scenarios where the trade pays off, and [Compare Your Options](/start/why-sent/comparison) works through the build-vs-buy decision in full. **Ready to try?** You don't need to commit to anything: light onboarding gives you API access and pre-built templates with just a verified email and phone number. See [Try Sent](/start/try-sent) for what's included. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/why-sent/use-cases.txt TITLE: What Sent Solves ================================================================================ URL: https://docs.sent.dm/llms/start/why-sent/use-cases.txt The messaging problems Sent is built to solve: guaranteed delivery, multichannel reach, and high-volume scale, and the teams that benefit most from each one. # What Sent Solves Sent isn't for every app, but once messaging grows beyond simple SMS notifications, the cracks start to show. At scale, you hit problems that adding another provider API can't fix. Those are infrastructure problems, and they are exactly what Sent is built to solve. ## Where Messaging Gets Hard Messaging might seem like a simple problem at first, but it quickly becomes complex as you scale. ### Deliverability Guarantees Some messages cannot fail. If they don't land, you break user trust or business operations: **Authentication & Security** - Login codes, 2FA, fraud alerts, password resets. If delivery fails, users are locked out or left vulnerable. These need speed, redundancy, and reliability that a single provider can't guarantee. **Transactional Confirmations** - Order confirmations, payments, shipping updates, account changes. Users depend on them to know something important happened, which is why [tracking message status](/start/guides/message-status-tracking) end to end matters as much as sending. **Critical Alerts** - Outages, security incidents, or time-sensitive events. Sometimes you need to [send on multiple channels at once](/start/guides/sending-messages) to make sure users actually see them. ### Multichannel Needs As your user base diversifies, "just send SMS" stops working quickly: **Geography** - SMS dominates in the U.S., WhatsApp in Latin America, and that mix keeps shifting. **Demographics** - Younger users may lean on WhatsApp or iMessage. Older users expect SMS. Different devices and habits mean no single channel covers everyone. **Device Capabilities** - Some users have flip phones. Others expect interactive, media-rich messages. You need to adapt per device, not pick one lowest-common-denominator channel. ### Scaling Up High-volume messaging introduces problems you can't solve with quick fixes: **Cost Pressure** - At scale, every cent per message matters. Static routing wastes money. [Dynamic routing](/start/concepts/unified-messaging) based on price and delivery performance saves it. **Reliability** - Depending on one provider is a single point of failure. Intelligent multi-provider failover is a must. **Compliance** - Opt-outs, regulations, content checks. At low volume you can handle this manually; at high volume you need automation, from [opt-out handling](/start/guides/opt-out-and-consent) to [regulatory compliance](/start/advanced/compliance-regulations), or you lose time and risk fines. ## When Sent Makes a Difference What **Sent** provides: - **Multi-channel delivery**: One API for [every supported channel](/start/concepts/channels): SMS, WhatsApp, RCS, and future ones. No juggling providers. - **Deliverability guarantees**: If a missed delivery is a business issue, you need fallback and redundancy. - **Global reach at scale**: Regional optimization and compliance built in, instead of cobbling together country-by-country setups. - **Engineering focus**: Keep your team focused on your product, not fighting messaging edge cases. - **RCS rich messaging**: Reach Android users with branded, interactive messages without building a separate integration. Automatic fallback to SMS means no recipient is left out. ### RCS Use Cases [RCS](/start/concepts/channels#rcs) unlocks richer interactions for Android users that plain SMS cannot match. **Interactive Campaigns** - Suggestion chips generated from [template buttons](/start/guides/working-with-templates) let you gather quick feedback ("Rate your experience: Good / Poor") or guide users through a flow ("Track Order" / "Contact Support") directly in the message thread. **Branded Transactional Messages** - Delivery confirmations and appointment reminders sent with your company logo and verified checkmark build trust compared to an unknown short code number. Rich cards and scrollable product carousels are part of the RCS standard but are not yet available through Sent. RCS messages currently render as text plus suggestion chips. ## Who Benefits Most **B2C SaaS** - Apps where messaging drives engagement, security, or operations. **Financial Services** - Banking, payments, and investment platforms where trust depends on reliable, compliant delivery. **E-commerce & Marketplaces** - Order/shipping notifications and customer service updates at scale. **Developer Platforms & ISVs** - Platforms embedding messaging into their own products without wanting to manage provider sprawl. **Emerging Segments** - AI-driven systems that generate huge volumes of context-sensitive messages. - IoT and connected devices that need guaranteed delivery across varying endpoints. - Logistics and supply chain platforms coordinating fast-moving operations. Sent fits best when messaging is core to your business, multi-channel, or at scale. For the cases where a direct provider integration or an in-house build serves you better, [Compare Your Options](/start/why-sent/comparison) works through the full decision. If your scenario already matches the ones above, the [quickstart](/start/quickstart) takes you from account setup to your first delivered message. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/start/why-sent/what-is-sent.txt TITLE: What is Sent ================================================================================ URL: https://docs.sent.dm/llms/start/why-sent/what-is-sent.txt A quick introduction to Sent, what it is, what it does, and the problems it solves for teams building with messaging. # What is Sent Sent is a **unified messaging API** that lets you send SMS, WhatsApp, and RCS messages through a single integration, without stitching together multiple providers, managing compliance rules per channel, or building your own fallback logic. You send one request, and Sent handles the routing, delivery, failover, and compliance, across every channel and every country you operate in. ## What Sent Does ## The Problems Sent Solves ### Managing multiple providers is expensive and fragile Most teams start with a single SMS provider. Then they add WhatsApp. Then a second SMS provider for better rates in certain countries. Before long, you're maintaining multiple integrations, dealing with inconsistent APIs, and debugging delivery failures across different dashboards. Sent replaces all of that with one API. One set of credentials. One place to see what's happening. ### Messages that must arrive sometimes don't Authentication codes, payment confirmations, security alerts: these messages have zero tolerance for failure. A single-provider setup has no fallback when that provider has an outage or a carrier issue in a specific region. Sent's routing layer monitors delivery performance in real time and fails over automatically, so you're not the one scrambling when a provider goes down at 2 AM. ### Compliance is a moving target US 10DLC registration, WhatsApp Business Account approvals, country-specific sender ID requirements, opt-out keyword handling: every channel and every market has its own rules, and they change. Sent manages the compliance layer on your behalf. Your team focuses on the product; Sent handles the regulations. ### Scaling messaging is a full-time job Rate limits, throughput management, cost optimization across providers, routing logic per country: at low volume this is manageable. At scale, it becomes its own engineering project. Sent handles high-volume messaging with routing optimized across providers, so you're not overpaying or hitting limits. ## What You Get - **Unified API**: one endpoint for SMS, WhatsApp, and RCS - **Message templates**: create once, send across all channels - **Contacts**: manage recipients with channel preferences attached - **Sender Profiles**: control the identity your messages come from - **Webhooks**: real-time delivery status and event updates - **Activities log**: full visibility into every message sent - **Dashboard**: manage everything without writing a line of code You can start sending in minutes, with no credit card and no lengthy setup: verify your email and phone number to get API access and pre-built templates. See [Try Sent](/start/try-sent) for exactly what's included. ## How It Fits Into Your Stack Sent sits between your app and the messaging providers. Your code calls the Sent API; Sent takes care of everything downstream. ``` Your App → Sent API → SMS / WhatsApp / RCS ``` You keep full control over what gets sent and to whom. Sent handles the how and where. --- Ready to go deeper? Explore [what Sent is built for](/start/why-sent/use-cases), [how it compares to alternatives](/start/why-sent/comparison), or jump straight to the [Quickstart](/start/quickstart). --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/account-activation.txt TITLE: Account Activation & KYC Review ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/account-activation.txt Resolve delays with account approval, KYC verification, TCR submission, and post-approval onboarding issues # Account Activation & KYC Review Account activation gates everything else you do on Sent. This guide covers every stage of the process, from initial KYC submission through to channel setup, with solutions for the issues developers encounter most often. The number one cause of activation delays is incomplete information. Before reaching out to support, double-check that all form fields are filled, your website is live, and it includes both a **privacy policy** and a **messaging opt-in disclosure**. Getting these right up front is the fastest way to move through review. ## "Waiting for approval" with no ETA or status updates **Symptoms:** After submitting your application on the final **Review & Submit** step of the onboarding wizard, the dashboard shows a message like: > Waiting for approval: Your compliance details are under review. You'll be notified once approved. **What's happening:** - Sent's compliance team reviews every submission manually to satisfy carrier and regulatory requirements. - Typical review time is **1-3 business days**. It can take longer if your submission is incomplete or requires clarification. - If you are targeting US SMS, your business details are also submitted to **The Campaign Registry (TCR)** for 10DLC approval, which adds additional processing time (see [TCR submission pending](#tcr-submission-pending-with-no-visibility) below). **What to do:** 1. Check your email inbox, including spam and promotions folders, for any "Action Required" messages from Sent. These emails request clarification or corrections needed before approval can proceed. 2. Verify that every field in the compliance form is filled out completely. Partial submissions are held until the missing information is provided. 3. Confirm your website is live and accessible. A website that returns a 404 or is password-protected will delay review. 4. If you have been waiting **more than 5 business days** with no communication, email [support@sent.dm](mailto:support@sent.dm) with the email address associated with your Sent account. ## KYC / compliance form confusion: what's required **Symptoms:** You are unsure what information to provide, or your submission was returned for corrections. The compliance form asks for your legal business name, tax ID (EIN in the US), registered business address, a live website URL, a use case description, and the URL where end users opt in to receive messages. For field-by-field guidance, including what to do if you don't have a website yet, see [Compliance Form Questions](/troubleshooting/compliance#compliance-form-questions). **Common rejection reasons:** a missing privacy policy, no visible messaging opt-in disclosure, a website that is not live, or a vague use case description. [Compliance Submission Rejected](/troubleshooting/compliance#compliance-submission-rejected) explains each one and how to resubmit. ## Individual developer vs business entity **Symptoms:** You are an independent developer or sole trader and are unsure whether you can register without a formal business entity. **Solution:** Sent requires business-level verification for all accounts. This is a regulatory requirement imposed by carriers and cannot be waived. However, you do **not** need to be a registered corporation. - **Sole traders and self-employed individuals** can register. Use your personal name as the business name and provide your tax registration number (SSN or EIN in the US, UTR in the UK, or the equivalent in your jurisdiction). - **Side projects and hobby apps** still require the same business-level details. Carriers enforce these rules uniformly regardless of the scale of your messaging. - If you are unsure which tax ID to use, consult your local tax authority or accountant. Sent cannot provide tax advice. ## What Sent reviews during KYC Every submission is checked against five criteria before approval: - **Valid business details**: business name, registered address, and tax ID must be verifiable and match tax-authority records. - **Active website**: reachable, with a clearly linked privacy policy page. - **Clear messaging opt-in mechanism**: your website or app shows how end users consent to receiving messages. - **Messaging use case**: what you send, to whom, and how frequently; used to classify your traffic type with carriers. - **Compliance with local regulations**: your use case must comply with the laws of your target regions (TCPA, GDPR, PECR, CASL, and others); [Compliance & Regulations](/start/advanced/compliance-regulations) summarizes each framework with authoritative sources. Sent does not provide legal advice. If you are unsure whether your messaging use case complies with regulations in your target market, consult a legal professional before submitting your application. ## Cannot use platform features while waiting **Symptoms:** Dashboard sections such as Contacts, API Keys, Templates, and Channels are locked or greyed out while your account is under review. **What you can do during review:** - Browse the [getting-started guides](/start) and [API reference](/reference/api) to plan your integration. - Explore the [Templates](/start/concepts/templates) section to learn about template structure and draft templates locally. You will not be able to submit them until approved. - Set up your local development environment and prepare your codebase for integration. **What you cannot do during review:** - Generate or view API keys - Create or manage contacts - Submit templates for carrier approval - Send messages (including sandbox/test messages) - Configure webhook endpoints Once approved, you will receive an email notification and all dashboard sections will unlock automatically. ## Post-approval onboarding issues After your account is approved, you set up your sending channels and numbers in the dashboard. The following issues may arise at this stage. ### No phone numbers available **Symptoms:** The phone number dropdown on the **Choose Your Sender Number** step of the Sender Profile wizard is empty or shows no available numbers for your region. **Solution:** 1. Click the **Refresh** button to reload available inventory. 2. Phone number availability varies by country and region. Some regions may be temporarily out of stock. 3. If no numbers appear after refreshing, contact [support@sent.dm](mailto:support@sent.dm) with your preferred country and region. The team can check inventory and provision a number manually if stock is available. ### "Something went wrong" during Sender Profile setup **Symptoms:** The Sender Profile setup step displays: "Something went wrong: We could not complete your onboarding. Please try again." **Solution:** 1. Click **Try Again** once. This error is often caused by a transient backend issue. 2. If the error persists after retrying, clear your browser cache and attempt the setup in an incognito/private window. 3. If it still fails, contact [support@sent.dm](mailto:support@sent.dm) with a screenshot of the error. Include the browser and OS you are using. ### Messages sending from short code instead of your dedicated number **Symptoms:** After setting up a dedicated phone number, messages are still being sent from a shared short code. The outbound number is determined by the Sender Profile your API request is authenticated as, not by a request body field. See [Messages Routing to Wrong Sender Number](/troubleshooting/messages-not-delivered#messages-routing-to-wrong-sender-number) for the fix. ### WhatsApp channel requires a Facebook account **Symptoms:** The WhatsApp channel setup prompts you to connect a Facebook account, which you were not expecting. **Solution:** WhatsApp Business API channels are managed through Meta (Facebook). To set up WhatsApp on Sent, you need: 1. A Facebook account (personal account is fine for initial setup) 2. A Meta Business Account that has completed Meta Business Verification. Create one at [business.facebook.com](https://business.facebook.com) if you don't have one. Meta requires verification before a WhatsApp Business Account can be shared with partners like Sent, so complete it before starting the connection flow. This is a Meta requirement, not a Sent-specific one. The dashboard will guide you through the connection flow step by step; see [Prerequisites for WhatsApp setup](/troubleshooting/whatsapp-setup#prerequisites-for-whatsapp-setup) for the full list. ## TCR submission pending with no visibility **Symptoms:** Your account is approved by Sent, but you see a message like: > Your case has been submitted to TCR and Sent is waiting for a response. You have no visibility into the status or timeline. **What's happening:** For US SMS messaging, all businesses must be registered with **The Campaign Registry (TCR)** for 10DLC (10-digit long code) compliance. This is a separate process from Sent's internal KYC review and is handled by an external body. **Timeline:** - TCR review typically takes **3-7 business days** after Sent submits your details. - In some cases, particularly for new brands or unusual use cases, it can take longer. - This process runs in addition to Sent's own review, so total time from signup to sending can be 1-2 weeks for US SMS. **What to do:** - **Nothing.** Sent handles the TCR submission on your behalf. There is no action required from you at this stage. - If TCR rejects the submission, the Sent support team will contact you with details about what needs to be corrected (typically business name mismatches or use case clarification). - If you have been waiting more than **10 business days** since your Sent account was approved and you still cannot send US SMS, contact [support@sent.dm](mailto:support@sent.dm). TCR approval is only required for US SMS messaging via 10DLC numbers. If you are sending messages in other countries or using WhatsApp exclusively, this step does not apply to you. ## API returns 401 Unauthorized **Symptoms:** API calls return `401 Unauthorized` with an "Invalid or missing API key" error. Because your account is still partway through onboarding, the error looks activation-related. **Why this happens:** Onboarding status never causes a `401`. A valid API key authenticates at every stage of the activation process, including while compliance review is still in progress. Until you complete onboarding, the platform caps how many messages you can send, but that cap is enforced as a sending quota with its own error message, never as an authentication failure. A `401` always means the credential on the request failed: - The `x-api-key` header is missing from the request. - The key value is wrong: truncated, padded with whitespace, or copied from a different environment. - The key was rotated or deleted, so the value your client sends no longer exists. If your client keeps retrying with a bad key, the API stops returning `401` after 10 consecutive failures and instead returns `429 Too Many Requests` ("Too many failed authentication attempts") until a lockout period expires. **Solution:** 1. Open **Dashboard → API Keys** and compare the key character for character with the value your client sends. Watch for whitespace or truncation introduced by environment-variable handling. 2. Confirm the key is sent in the `x-api-key` request header. 3. If the key was recently rotated, update every environment that still holds the old value. 4. If you are receiving `429`, stop the retry loop first, fix the key, and wait for the period in the `Retry-After` response header before retrying. --- ## 403 Forbidden on organization-scoped endpoints **Symptoms:** Some API endpoints, such as listing users, return a "You do not have access to this organization or profile" error with status `403 Forbidden`. Other endpoints (such as listing profiles) respond correctly with the same key. **Why this happens:** User-management endpoints check the email address on the account your API key belongs to. That email must either own the target organization or profile, or hold an active user role on it granted by invitation. If it has neither, the endpoint returns `403`. Some actions, such as inviting a user, additionally require the `admin` role. A separate `403` ("Profile API keys cannot use x-profile-id") occurs when a request sends the `x-profile-id` header with a profile-scoped API key. Only organization API keys can act on behalf of profiles. Your account is an organization from the moment it is created. There is no separate upgrade step, and creating a Sender Profile does not change which endpoints your key can access. **Solution:** 1. Confirm the API key belongs to the organization or profile you are targeting. Retrieve the key from **Dashboard → API Keys** while signed in to that account. 2. If a team member needs access, ask the organization owner to invite their email under **Dashboard → Users**. Actions such as inviting users require the member's role to be `admin`. 3. If the failing request sends the `x-profile-id` header, switch to an organization API key, or remove the header when authenticating with a profile key. --- ## Sender Profile creation fails with a "duplicate key" error **Symptoms:** Clicking **Unlock Sender Profiles** or attempting to create a Sender Profile returns an error along the lines of: *"duplicate key violates unique constraint."* **Why this happens:** This is a database-level conflict that can rarely occur during Sender Profile creation. It is not caused by anything in your configuration and requires Sent support to clear. **Solution:** 1. Contact [support@sent.dm](mailto:support@sent.dm) with your account email and the exact error message. 2. Sent support will identify and clear the conflicting record. 3. Once resolved, you will be notified to retry Sender Profile creation. This error blocks only Sender Profile creation. Your API key keeps working while you wait, so you can continue sending messages via an existing sender and working with templates. --- ## Still waiting? If you have worked through the preceding steps and your issue is not resolved, contact the Sent support team: - **email:** [support@sent.dm](mailto:support@sent.dm) - **Include:** The email address associated with your Sent account, a description of where you are stuck, and any error messages or screenshots from the dashboard. - **Response time:** The support team typically responds within 1 business day. For general questions about the platform, check the [FAQ](/start/reference-guides/faq) or browse the [getting-started guides](/start). ## Related Guides - [Login, Signup & OTP Verification](/troubleshooting/authentication): if you're having trouble logging in or verifying your phone number - [Compliance & 10DLC Issues](/troubleshooting/compliance): for details on 10DLC registration, A2P requirements, and compliance form guidance - [WhatsApp Setup](/troubleshooting/whatsapp-setup): if you're setting up WhatsApp as a channel after account approval --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/authentication.txt TITLE: Login, Signup & OTP Verification ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/authentication.txt Troubleshoot common issues with signing up, logging in, OTP delivery, phone verification, and account access on Sent.dm # Login, Signup & OTP Verification Issues with signing up, logging in, or verifying your phone number are among the most commonly reported problems. This guide walks through each scenario with concrete steps to get you unblocked. If you can sign in but an API request comes back as HTTP 403 with the code `AUTH_004`, your role is blocking the request rather than your login: see [Roles and Permissions](/reference/api/roles-and-permissions) for the operations each role can perform. Phone verification and message delivery use different SMS routes. If you cannot receive the OTP code during signup, it does not mean your customers won't receive messages through Sent. ## Quick Diagnostic Checklist Before working through the sections below, run through these checks first. They resolve the majority of login and signup issues: - [ ] **Browser is up to date**: outdated browsers can cause unexpected errors during signup - [ ] **JavaScript is enabled**: the authentication flow requires JavaScript - [ ] **No VPN or proxy active**: VPNs can interfere with phone verification and OTP delivery - [ ] **Cookies and local storage are enabled** (required for session management) - [ ] **Phone number includes correct country code**: select your country from the dropdown, do not type the code manually - [ ] **Phone has SMS capability**: data-only SIMs cannot receive OTP codes - [ ] **Check spam/filtered folders**: both SMS and email OTPs can be filtered - [ ] **Wait at least 60 seconds** before requesting a new OTP, since earlier requests may still be in transit --- ## OTP Not Delivered to Phone Number This is the single most reported authentication issue. You reach the phone verification step at `app.sent.dm/auth/verify-phone`, request an OTP, and the SMS never arrives. ### Why This Happens - **Carrier-level filtering**: Mobile carriers in certain regions aggressively filter automated SMS traffic. This is especially common in Algeria, the UAE, Turkey, Ghana, and parts of West and East Africa. - **SMS route unavailability**: The SMS route to your carrier may be temporarily down or congested. - **Network delay**: In some cases, OTP messages are delayed by several minutes rather than lost entirely. ### What to Do 1. **Wait at least 60 seconds**, then tap "Resend code." The first message may still be in transit. 2. **Check your SMS inbox carefully**: some phones place messages from short codes or unknown senders into a separate folder (for example, "Filtered messages" on Samsung devices, or "Transaction" on some Android phones). 3. **Disable any SMS-blocking apps**: apps like Truecaller or carrier-provided spam filters can intercept OTP messages. 4. **Try a different phone number** if you have access to one, such as a secondary SIM or a family member's number temporarily. 5. **Contact support** at [support@sent.dm](mailto:support@sent.dm) with your email address and the phone number you are trying to verify. The team can manually complete phone verification on your account. In regions with heavy carrier filtering such as Algeria, the UAE, Turkey, Ghana, and parts of West and East Africa, this is often the fastest path. --- ## Country Code Not Listed in Phone Verification Dropdown The phone verification step includes a dropdown for selecting your country code. Some users report that their country does not appear in the list. For issues related to phone number availability for *sending* messages (rather than verification), see [Phone Number & Sender ID Issues](/troubleshooting/phone-numbers). ### Countries Reported as Missing Users have reported difficulty finding country codes for Rwanda (+250), Uganda (+256), Kenya (+254), and others. In most cases the country is present but not immediately visible. ### What to Do 1. **Use the search field** at the top of the dropdown. Type your country name (for example, "Rwanda") or the numeric code (for example, "250") to filter the list. 2. **Scroll through the full list**: the dropdown is sorted alphabetically and may require scrolling. 3. **If your country is genuinely not listed**, email [support@sent.dm](mailto:support@sent.dm) with your account email and phone number. Sent support can manually verify your phone number. Phone verification and message sending are separate systems. Even if your country code is not available in the verification dropdown, Sent may still support sending messages to that country. Contact [support@sent.dm](mailto:support@sent.dm) to confirm delivery coverage for your target region. --- ## Signup Returns 500 Internal Server Error After filling out the signup form and clicking "Sign up," you see one of the following errors: - `Sign-up failed — Server Action was not found` - `500 Internal Server Error` ### Why This Happens This is an intermittent server-side issue. It is not caused by your input or account details. ### What to Do 1. **Clear your browser cache** and cookies for `sent.dm` and `app.sent.dm`. 2. **Try incognito/private browsing mode**: this rules out extensions and cached state as the cause. 3. **Try a different browser**: switch from Chrome to Firefox (or vice versa) to isolate browser-specific issues. 4. **Wait 15 to 30 minutes and retry**: transient server errors typically resolve within this window. 5. **If the error persists for more than a few hours**, contact [support@sent.dm](mailto:support@sent.dm) with the exact error message and a screenshot if possible. --- ## "Invalid email address" Error on Login You see the message: *"Invalid email address. Please provide a valid email address that can receive messages"* even though you are entering a valid email. ### Why This Happens - **email domain blocklist**: Some email domains (particularly disposable email providers) are blocked during signup. - **Unusual characters**: email addresses with special characters (`+`, non-ASCII characters) may not pass validation. - **Browser autofill corruption**: Autofill can silently insert hidden characters or whitespace into the email field. ### What to Do 1. **Type the email address manually** instead of relying on paste or autofill. Click inside the field, clear it completely, and type each character. 2. **Try a mainstream email provider**: Gmail, Outlook, or Yahoo addresses are universally accepted. 3. **Clear your browser autofill data** for the `sent.dm` domain. 4. **If you previously had an approved account** and now see this error, your account may require re-verification. Contact [support@sent.dm](mailto:support@sent.dm) with your email address and business name. --- ## "Send Limit Reached" Error You see: *"Send Limit Reached. You've reached the maximum number of attempts"* when trying to request a new OTP code. ### Why This Happens After too many OTP requests in a short period, the system temporarily blocks further attempts. This is a security measure to prevent abuse of the verification system. ### What to Do 1. **Wait approximately 1 hour**: the rate limit resets automatically after this period. Do not continue attempting during this time, as it may extend the cooldown. 2. **Contact support** at [support@sent.dm](mailto:support@sent.dm) if you need immediate access. The team can reset your OTP attempt counter manually. --- ## Phone Verification Not Working (General) If the phone verification step fails in a way not covered by the preceding sections (the page hangs, the "Verify" button does nothing, or you see an unexpected error), try the following general steps. ### What to Do 1. **Switch to a different browser.** Chrome and Firefox on desktop tend to have the fewest issues. 2. **Disable your VPN or proxy.** Some VPN exit nodes are flagged by fraud-prevention systems, which can block OTP delivery or cause verification to fail silently. 3. **Confirm your SIM supports SMS.** Data-only SIMs, eSIMs configured for data only, and some VoIP numbers cannot receive SMS-based OTPs. 4. **Check the phone number format.** Enter only the digits after the country code: no spaces, dashes, or parentheses. The country code is selected separately from the dropdown. 5. **Try on a mobile device** if you have been using a desktop browser, or vice versa. --- ## Magic Link or Email Confirmation Not Arriving You requested a login link or email confirmation code, but nothing appears in your inbox. ### What to Do 1. **Check your spam and junk folders.** Search for emails from `team@sent.dm`. 2. **Add `team@sent.dm` to your contacts or allowlist.** This prevents future emails from being filtered. 3. **Check with your IT department** if you are using a corporate or managed email account. Some organizations block external senders or filter automated messages at the server level. 4. **Be aware of expiration.** OTP codes sent by email expire after 1 hour. If you find an older code, request a fresh one rather than trying to use it. 5. **Request a new code** by returning to the login page and starting the flow again. If you use a corporate email with strict filtering, consider signing up with a personal email address and then updating your account email later, or ask your IT team to allowlist `team@sent.dm`. --- ## Facebook or Meta Account Restriction Blocking Login If you signed up or logged in using your Facebook or Meta account and that account is subsequently restricted or suspended by Facebook, you will be unable to access your Sent account. ### What to Do 1. **Resolve the restriction directly with Facebook.** Sent cannot override Facebook's account restrictions. Visit [facebook.com/help](https://www.facebook.com/help/) to appeal or resolve the issue. 2. **Contact Sent support** at [support@sent.dm](mailto:support@sent.dm) to request a change of login method. The team can unlink your Facebook login and switch your account to email-based authentication, provided you can verify your identity. --- ## Delete or Reset a Previous Account If you created an account with the wrong email address, or need to start fresh with a different email, you cannot do this self-service. Account deletion requires manual action from Sent support. ### What to Do 1. **Send an email to [support@sent.dm](mailto:support@sent.dm)** with the following details: - The email address associated with the account you want to delete - The new email address you want to use going forward - Your business name (for identity verification) 2. Sent support will delete or deactivate the old account and confirm when you can sign up again with the new email. Account deletion is permanent. Any API keys, templates, contacts, and message history associated with the old account will be removed and cannot be recovered. --- ## Still Stuck? If none of the preceding solutions resolved your issue: 1. **Gather the following information** before contacting support: - Your email address - The phone number you are trying to verify (with country code) - The exact error message or screenshot - The browser and operating system you are using - The approximate time the issue occurred 2. **Send an email to [support@sent.dm](mailto:support@sent.dm)** with the details from the preceding list. Most authentication issues are resolved within one business day. ## Related Guides - [Account Activation & KYC Review](/troubleshooting/account-activation): if your account is stuck in the approval or compliance review stage - [Phone Number & Sender ID Issues](/troubleshooting/phone-numbers): if you're having trouble with phone number availability for sending messages - [WhatsApp Setup](/troubleshooting/whatsapp-setup): if your issue is specifically with connecting a WhatsApp Business Account --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/compliance.txt TITLE: Compliance & 10DLC Issues ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/compliance.txt Troubleshoot compliance form submissions, 10DLC registration errors, A2P requirements, and country-specific messaging regulations on Sent.dm # Compliance & 10DLC Issues Compliance is a prerequisite for sending messages through Sent. If your compliance submission is incomplete, rejected, or missing required information, your messages will not be delivered. This guide covers the most common compliance-related issues and how to resolve them. Compliance requirements exist to protect consumers and are enforced by carriers and regulators. Non-compliant messaging can result in number suspension, fines, or account termination. If you're unsure about requirements for your use case, contact support before sending. --- ## Compliance Checklist Before submitting your compliance form, make sure you have the following ready. Having everything prepared upfront avoids back-and-forth and speeds up approval. - [ ] **Legal business name**: must match your official registration documents - [ ] **Business address**: the registered address of your business entity - [ ] **EIN or Tax ID**: for US businesses, your Employer Identification Number; for non-US businesses, the equivalent tax registration number - [ ] **Live website URL**: must be publicly accessible at the time of review (not localhost, not behind authentication) - [ ] **Privacy policy URL**: must be live and must mention SMS or messaging communications - [ ] **Opt-in mechanism URL**: a direct link to where end users consent to receive messages from you - [ ] **Use case description**: a clear explanation of what messages you send, who receives them, how they opted in, and how often they receive messages - [ ] **Sample messages**: one or two examples of the messages your app will send - [ ] **Opt-out instructions**: how recipients can stop receiving messages (for example, reply STOP) --- ## 10DLC Errors Blocking SMS Sending ### The Problem You have been approved on the Sent platform, set up your channels, and attempted to send SMS messages to US numbers, but messages fail with a 10DLC-related error. Sent registers your brand and campaign with The Campaign Registry (TCR) on your behalf when you submit your compliance form. For what 10DLC is and how the review works, see [What is 10DLC?](/start/concepts/10dlc); for the registration steps and form requirements, see the [10DLC Registration Guide](/start/advanced/10dlc-registration). ### Why Messages Fail Messages will fail with a 10DLC error if: - Your compliance submission was incomplete and TCR registration was not completed - Your brand or campaign was rejected by TCR - Your compliance form was approved but the TCR registration is still processing (this can take 3-7 business days) - You are sending messages that do not match your registered campaign use case ### How to Fix It 1. Go to **Dashboard → Compliance** and check the status of your submission. 2. Look for any rejection notices, pending items, or action items that require your attention. 3. If your status shows as approved but messages still fail, the TCR registration may still be propagating. Wait 24-48 hours and try again. 4. If you see a specific rejection reason, address the issue and resubmit your compliance form. 5. If the status is unclear or you cannot determine the issue, contact Sent support with your account email and the error message you are receiving. TCR registration typically completes within 3-7 business days after your Sent compliance form is approved. During this window, SMS to US numbers may not be delivered. --- ## SMS Test Messages Failing Right After Onboarding ### The Problem You just completed onboarding, created a template, and tried to send a test SMS to a US number, but the message failed. Everything else looks correct: your account is active, your template is set up, and the API is responding. Yet no SMS arrives. ### Why This Happens Sending SMS to US numbers requires an approved 10DLC brand and campaign registration. This registration is submitted as part of your onboarding compliance form, but approval from The Campaign Registry (TCR) typically takes **3–7 business days** after submission. Until that approval comes through, SMS messages to US numbers will not be delivered, even in a test environment. This is not an issue with your account, your template, or your API integration. It is a mandatory hold period imposed by US carriers. ### How to Test While Waiting for Approval During the approval window, you can still test your integration end-to-end using the **shared short code** available on your account. The shared short code is pre-registered and approved for testing purposes, so messages sent through it are delivered without waiting for your own 10DLC registration to complete. To send a test message using the shared short code: 1. Go to **Dashboard → Profiles** and look for the pre-configured **Shared Short Code** Sender Profile. 2. Send the message as that profile: use an API key created under it, or pass its ID in the `x-profile-id` header with an organization API key. See [Messages Routing to Wrong Sender Number](/troubleshooting/messages-not-delivered#messages-routing-to-wrong-sender-number) for both options. 3. Send to your own number or a test number to confirm delivery. The shared short code is intended for **testing only** while your 10DLC registration is pending. Once your brand and campaign are approved, switch to your dedicated number to send to real users. ### What to Expect in the Dashboard While your 10DLC registration is pending, test messages sent without the shared short code Sender Profile will appear in **Dashboard → Activities** with a delivery failure. The failure is expected. It reflects the carrier-level block on unregistered traffic, not a bug in your setup. Once your registration is approved, the same API request will deliver successfully without any code changes on your side. ### Checking Your Registration Status 1. Go to **Dashboard → Compliance** and check the status of your submission. 2. If the status is **Approved**, your TCR registration is either still propagating (allow 24–48 hours) or there is an issue with your campaign details. Contact support. 3. If the status is **Pending**, your submission is still under review. Typical review time is 3–7 business days. 4. If the status is **Action Required** or **Rejected**, follow the guidance in the dashboard or see [Compliance Submission Rejected](#compliance-submission-rejected) below. Do not attempt to send live SMS campaigns to US recipients while your 10DLC registration is pending. Unregistered traffic is filtered by US carriers and may negatively impact your sender reputation. --- ## Compliance Form Questions ### The Problem The compliance form asks for business-level details that may seem excessive, especially if you are an individual developer or working on a small project. Understanding what is needed and why helps you fill it out correctly the first time. ### Required Information Explained **Legal Business Name** Enter the name your business is registered under, not a product name or DBA. This must match the name associated with your EIN or tax ID. If you are a sole proprietor, use your full legal name. **Business Address** Your registered business address. This is verified against public records, so it must match your official filings. **EIN / Tax ID** For US businesses, this is your 9-digit Employer Identification Number. For businesses outside the US, provide your local tax registration number or business registration number. **Website URL** A publicly accessible website that represents your business. The site must be live and reachable at the time of review. Placeholder pages, under-construction pages, or password-protected sites will result in rejection. **Message / Call-to-Action Field** This field should contain a direct URL to the page where users opt in to receive messages. For example, if users sign up on `https://yourapp.com/signup` and check a box to receive SMS notifications, that URL goes here. **Use Case Description** Clearly explain: - What type of messages you will send (for example, order confirmations, appointment reminders, OTP codes) - Who receives the messages (for example, registered users of your platform) - How recipients opted in (for example, checked a consent box during registration) - Approximate message frequency (for example, 2-3 messages per week per user) **Privacy Policy URL** Must link to a live privacy policy that explicitly mentions SMS or messaging communications, including how users can opt out. ### If You Don't Have a Website Yet Create a simple landing page that includes: 1. Your business name and contact information 2. A privacy policy (templates are available online for common jurisdictions) 3. A description of your messaging service and how users opt in 4. An opt-in mechanism (even a simple checkbox with disclosure text) This is sufficient to pass review. You can update the site later. ### Building on Behalf of Another Business? If you are a developer building a messaging integration for another business, enter that business's details on the compliance form, not your own. The compliance registration must reflect the entity that end users will receive messages from. --- ## A2P Registration Requirements ### The Problem You are not sure what registration is required for your messaging use case, or the requirements differ from what you expected. ### Requirements by Channel Virtually all messages sent through Sent are A2P (Application-to-Person); [What is 10DLC?](/start/concepts/10dlc) explains the A2P/P2P distinction and why US carriers gate this traffic. **US SMS (10DLC)** - 10DLC registration is mandatory for all A2P SMS to US numbers. Sent registers your brand and campaign with TCR as part of your compliance submission; both must be approved before messages are delivered. See the [10DLC Registration Guide](/start/advanced/10dlc-registration). **International SMS** - Requirements vary by country: some require sender ID pre-registration (for example, India, Philippines), and some restrict content or block international A2P SMS entirely. Sent's compliance review covers foundational requirements; country-specific details are addressed during channel setup. [Compliance & Regulations](/start/advanced/compliance-regulations) summarizes the major frameworks per region. **WhatsApp** - You need a Meta-approved WhatsApp Business Account (WABA), and message templates must be approved by Meta before use. Sent guides you through this during channel configuration; see [WhatsApp Setup](/troubleshooting/whatsapp-setup) if you get stuck. Sent's compliance review covers the foundational requirements for your account. Channel-specific requirements (such as WhatsApp template approval or country-specific sender ID registration) are handled during channel setup and configuration. --- ## Combining Marketing Consent with a Mandatory Agreement A common design question is whether the marketing messaging opt-in checkbox can be merged with a mandatory terms of service or purchase confirmation checkbox, so that completing an action (booking, purchase, account creation) automatically grants marketing consent. ### Why this approach is risky This pattern, sometimes called "bundled consent," is broadly non-compliant and can expose you to regulatory and carrier liability: - **US SMS (TCPA/CTIA):** The CTIA guidelines and TCPA require that consent to receive marketing text messages be obtained separately from unrelated agreements. Tying marketing opt-in to a required step in a purchase or signup flow is considered coercive and can constitute a TCPA violation. - **Meta/WhatsApp:** Meta's messaging guidelines require opt-in language to be presented clearly and distinctly. Consent must not be a required condition for accessing a service that is not itself a messaging service. - **EU/UK (GDPR/PECR):** GDPR explicitly prohibits bundled consent. Marketing consent cannot be a precondition for a non-messaging service. Consent must be freely given, specific, and separately obtained. ### What compliant opt-in looks like Present the marketing consent as a separate, optional, unchecked checkbox with clear language. Positioning it near (but distinct from) a mandatory agreement is acceptable, as long as it can be independently checked or left unchecked without blocking the user's action: > ☐ Agree to receive marketing messages from [Your Brand]. Message and data rates may apply. Reply STOP to opt out. Users who do not check the box must still be able to complete the purchase or sign up. For US SMS specifically, freely given consent that is not bundled with other agreements is a strict requirement, not a best practice. If your opt-in UI ties marketing consent to a purchase or account creation step, do not launch it for US audiences without legal review. If you are unsure whether your specific opt-in design satisfies the requirements for your target markets, contact [support@sent.dm](mailto:support@sent.dm) before going live. --- ## Compliance Submission Rejected ### The Problem Your compliance submission was rejected and you received an email explaining the reason. You need to understand what went wrong and how to fix it. ### Common Rejection Reasons **Website not live or not accessible** Your website URL returned an error, was behind authentication, or showed a placeholder page at the time of review. Make sure the site is publicly accessible and fully functional before resubmitting. **Privacy policy missing or incomplete** Your privacy policy either could not be found at the provided URL, or it does not mention SMS or messaging communications. Update your privacy policy to include: - That your service sends SMS or messaging communications - What types of messages are sent - How users can opt out of receiving messages - How user data related to messaging is handled **Opt-in mechanism not clearly visible** The reviewer could not find a clear opt-in mechanism on the URL you provided. Make sure: - The consent checkbox or opt-in prompt is visible without scrolling excessively - The opt-in language is explicit (for example, "I agree to receive SMS notifications from [Business Name]") - The opt-in is not pre-checked: users must actively consent **Business information does not match public records** The business name, address, or tax ID you provided does not match publicly available records. Double-check your information against your official business registration documents. **Use case violates Acceptable Use Policy** Sent prohibits certain categories of messaging, including but not limited to: - Illegal content or services - Deceptive or misleading messages - Phishing or social engineering - Cannabis, CBD, or controlled substance marketing (varies by jurisdiction) - Debt collection harassment - Messages without proper opt-in consent If your use case was flagged in error, contact support with a detailed explanation of your messaging purpose. ### How to Resubmit 1. Go to **Dashboard → Compliance**. 2. Review the rejection reason provided in the email or displayed in the dashboard. 3. Fix the identified issues. 4. Update your compliance details in the dashboard. 5. Resubmit the form for review. There is no limit on resubmissions, but repeated submissions without addressing the stated issues may delay review. --- ## Country-Specific Compliance Considerations Messaging regulations vary significantly by country. [Compliance & Regulations](/start/advanced/compliance-regulations) summarizes the major frameworks per region (TCPA, GDPR, PECR, CASL, and others) with links to the authoritative sources, and describes the controls Sent enforces automatically at send time. Sent's compliance review helps ensure you meet baseline platform requirements, but you are ultimately responsible for legal compliance in every jurisdiction where you send messages. Consult legal counsel if you are unsure about the regulations that apply to your use case. ### Carrier-Level Restrictions Beyond regulatory requirements, individual carriers may impose their own restrictions: - Content filtering that blocks certain keywords or message patterns - Rate limits that vary by carrier and sender reputation - Additional registration requirements for high-volume senders - Restrictions on certain message categories (for example, some carriers block loan or gambling-related content) These restrictions are not always publicly documented and can change without notice. If your messages are being filtered despite regulatory compliance, contact Sent support for guidance. For more on delivery failures caused by carrier filtering, see [Messages Not Delivered](/troubleshooting/messages-not-delivered). --- ## Still Stuck? If your compliance issue is not covered here or you need help understanding the requirements for your specific use case: 1. Check **Dashboard → Compliance** for any actionable items or status updates. 2. Review the rejection email (if applicable) for specific details about what needs to be fixed. 3. Contact Sent support at [support@sent.dm](mailto:support@sent.dm) with: - Your account email address - The specific compliance issue or error you are encountering - Any relevant screenshots from the dashboard - Your intended messaging use case and target countries ## Related Guides - [10DLC Registration Guide](/start/advanced/10dlc-registration): registration steps, campaign types, and the opt-in and autoresponse requirements reviewers check - [Compliance & Regulations](/start/advanced/compliance-regulations): the controls Sent enforces at send time, and per-region regulations with authoritative sources - [Account Activation & KYC Review](/troubleshooting/account-activation): if your account is still under review or your compliance submission is pending - [Messages Not Delivered](/troubleshooting/messages-not-delivered): if your compliance is approved but messages are failing to deliver - [Phone Number & Sender ID Issues](/troubleshooting/phone-numbers): for sender ID registration and number provisioning questions --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting.txt TITLE: Troubleshooting ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting.txt Diagnose and fix the most common Sent issues: WhatsApp, RCS, and SMS delivery failures, account activation, template rejections, authentication, and compliance. # Troubleshooting Something not working as expected? This section covers the issues customers run into most often, with clear diagnosis steps and solutions for each. Browse by category below, or scan the [Quick Error Reference](#quick-error-reference) table to identify your problem by HTTP status or error code. If you're unable to find a resolution here, include your **request ID**, **timestamp**, and a sanitised code snippet when [contacting support](/start/reference-guides/support). It significantly speeds up diagnosis. ## Browse by Category } /> } /> } /> } /> } /> } /> } /> ## Quick Error Reference Map an HTTP status or error code directly to a solution: | Status / Code | Meaning | Go to | |---|---|---| | `AUTH_002` | Missing or invalid API key | [Messages Not Delivered](/troubleshooting/messages-not-delivered) | | `401 Unauthorized` | API key not accepted | [Messages Not Delivered](/troubleshooting/messages-not-delivered) | | `403 Forbidden` | Key lacks permission or KYC incomplete | [Account Activation](/troubleshooting/account-activation) | | `422 Unprocessable Entity` | Request body failed validation | [WhatsApp Templates](/troubleshooting/template-issues) | | `429 Too Many Requests` | Rate limit exceeded | [Error Handling Guide](/start/guides/error-handling) | | `500 / 503` | Sent-side error or temporary outage | [Status page](https://status.sent.dm) | | `ERR_ECOSYSTEM_ENGAGEMENT` | Meta anti-spam filter triggered | [Messages Not Delivered](/troubleshooting/messages-not-delivered) | | `TEMPLATE_NOT_APPROVED` | Template pending or rejected | [WhatsApp Templates](/troubleshooting/template-issues) | | `INSUFFICIENT_BALANCE` | Account balance too low to send | [FAQ: Pricing & Billing](/start/reference-guides/faq) | | `INVALID_PHONE_NUMBER` | Number format incorrect | [Phone Numbers](/troubleshooting/phone-numbers) | | `CHANNEL_UNAVAILABLE` | WhatsApp or RCS not available for recipient | [Messages Not Delivered](/troubleshooting/messages-not-delivered) | | RCS always falls back to SMS | RCS Agent not approved or recipient lacks RCS support | [Messages Not Delivered](/troubleshooting/messages-not-delivered) | | `MESSAGE_FILTERED` | Carrier-side content filter triggered | [Messages Not Delivered](/troubleshooting/messages-not-delivered) | | Sign-up failed / Server Action error | Signup form error | [Login & Signup](/troubleshooting/authentication) | | OTP not received | Phone verification code not arriving | [Login & Signup](/troubleshooting/authentication) | ## Diagnostic Checklist Before diving deep, run through these quick checks. They resolve the majority of reported issues: - **KYC is approved**: most features are locked until your account clears compliance review - **API key is present** in the `x-api-key` request header (not `Authorization: Bearer`) - **API key is active** and has not been rotated or revoked in the dashboard - **Phone numbers use E.164 format** (for example, `+14155552671`, not `14155552671` or `(415) 555-2671`) - **Templates are approved** before sending: WhatsApp templates require external Meta review, while SMS templates need no external approval; check the template status in your dashboard - **Sender Profile ID is included** in API calls if you're using a dedicated number - **Sandbox mode is off** if you expect real message delivery - **Account balance is positive**: messages are not queued when balance is zero ## Not Finding Your Issue? } /> } /> } /> --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/messages-not-delivered.txt TITLE: Messages Not Delivered ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/messages-not-delivered.txt Diagnose and fix WhatsApp, RCS, and SMS delivery failures: API-vs-dashboard mismatches, Meta anti-spam errors, country filtering, sender routing, and failover. # Messages Not Delivered Message delivery failures are the most commonly reported issue on Sent. This guide walks through every major failure scenario, from API misconfiguration to carrier-level filtering, with concrete steps to resolve each one. ## Diagnostic Flowchart Before reading the full guide, use this decision tree to narrow down the problem: The **Activities** page in the Sent dashboard is the single best diagnostic tool for delivery issues. Every message (whether sent via API or dashboard) is logged with its delivery status, channel used, error codes, and timestamps. Navigate to **Dashboard → Activities** and filter by recipient or message ID to inspect individual sends. --- ## WhatsApp Messages Fail via API but Work from Dashboard This is the most commonly reported delivery issue. You send a WhatsApp message through the API, the call returns a `2xx` success response, but the message ultimately shows as **failed** in your Activities log. Meanwhile, sending the exact same template from the Sent dashboard delivers successfully. ### Why This Happens When the API accepts your request, it means the request was syntactically valid and queued. The actual delivery attempt happens asynchronously, and it can fail for reasons the initial response cannot predict. The dashboard works because it builds the payload for you with the correct structure. ### Common Causes **1. Missing or incorrect `template` object** The v3 API requires a `template` object with either an `id` (the template's UUID) or a `name` field, never both. Passing the template ID as a flat string or using the wrong field name will cause the message to fail during processing. ```json // Incorrect - flat string { "to": ["+1234567890"], "template": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "channel": ["whatsapp"] } // Correct - template object with id { "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, "channel": ["whatsapp"] } // Also correct - template object with name { "to": ["+1234567890"], "template": { "name": "order_confirmation" }, "channel": ["whatsapp"] } ``` **2. Channel not specified correctly** The `channel` field is an array, and every entry must be a lowercase channel name: `"channel": ["whatsapp"]`. Values like `"WhatsApp"`, `"WHATSAPP"`, or `"wa"` fail request validation with a `400` response listing the allowed values: `sent`, `sms`, `whatsapp`, `rcs`. **3. Recipient does not have WhatsApp** If the recipient's phone number is not registered with WhatsApp, the message cannot be delivered on that channel. The API will accept the request, but delivery will fail. Check the Activities log for a status indicating the number is not WhatsApp-capable. **4. Template parameter mismatch** If your approved template expects three variables but your API request supplies two (or vice versa), the message will fail. Parameter types also matter: sending a string where the template expects a currency or date-time object will cause a rejection from Meta. ### How to Debug 1. Open **Dashboard → Activities** and find the specific message by recipient number or message ID. 2. Check the **error details**: the Activities log includes the downstream error from Meta or the carrier. 3. Send the same message from the dashboard and compare the payload. The dashboard's outbound payload is visible in the message detail view. 4. Verify your template ID is correct by checking **Dashboard → Templates** and confirming the template status is **Approved**. --- ## WhatsApp ERR_ECOSYSTEM_ENGAGEMENT / Error 131049 If you see the error code `131049` or the message `ERR_ECOSYSTEM_ENGAGEMENT`, this is a restriction imposed by Meta, not by Sent. ### What It Means Meta enforces anti-spam protections on WhatsApp Business messages. Error 131049 typically means one of: - **More than 24 hours have passed** since the customer last replied to you, and you are attempting to send a session (free-form) message instead of a pre-approved template. - **WhatsApp is throttling your messages** because Meta's systems detect patterns that resemble spam: high volume sends to recipients who rarely engage, repeated messages to the same number, or low read/reply rates. ### Solutions **Respect the 24-hour conversation window.** WhatsApp allows free-form session messages only within 24 hours of the customer's last message to you. Outside that window, you must use an approved message template to initiate contact. **Use approved templates to start conversations.** If you need to reach out to a customer who has not messaged you recently, always send a template message. Session messages sent outside the window will be rejected with error 131049. **Reduce message frequency.** If you are sending multiple messages to the same recipients in a short period, Meta may begin blocking delivery. Space out your sends and ensure each message provides clear value to the recipient. **Check for template reclassification.** Meta periodically reviews and reclassifies templates. A template originally approved as **Utility** may be reclassified to **Marketing**, which has stricter sending limits. If you believe your template was reclassified incorrectly: 1. Open [Meta Business Manager](https://business.facebook.com/). 2. Navigate to **WhatsApp Manager → Message Templates**. 3. Find the affected template and submit an appeal for reclassification. Error 131049 is enforced by Meta's infrastructure. Sent cannot override or bypass this restriction. The only path forward is to comply with Meta's messaging policies. --- ## SMS Not Delivered in Specific Countries SMS delivery relies on carrier agreements that vary by country. A message that delivers within seconds in the US may fail or be silently filtered in another region. ### Common Patterns - **Carrier-level filtering**: Countries including Algeria and several countries in the Middle East and Africa apply aggressive content filtering. Messages may be silently dropped without a delivery failure being reported back to Sent. - **Alphanumeric sender ID restrictions**: Some countries do not support alphanumeric sender IDs (for example, "MySaaS" as the sender name). In these countries, the sender ID may be silently converted to a local number or the message may be blocked entirely. - **Registration requirements**: Certain countries require pre-registration of sender IDs or message content before SMS can be delivered. Sending without registration results in filtering. ### Solutions 1. **Try a different sender ID type.** If you are using an alphanumeric sender ID, switch to a numeric sender ID (a dedicated phone number) for countries that do not support alphanumeric IDs. 2. **Contact Sent support** for country-specific routing information. Sent support can advise on the best sender type, content guidelines, sender ID coverage, and any registration requirements for your target country. 3. **Monitor delivery rates by country** in the Activities dashboard. A sudden drop in delivery rate for a specific country often indicates a carrier policy change. --- ## Messages Routing to Wrong Sender Number You configured a Sender Profile with a dedicated long code or toll-free number, but messages are still going out from a shared short code. ### Cause The v3 API does not accept a `sender_profile_id` field: the request body supports only `to`, `channel`, `template`, `text`, and `sandbox`. The outbound number is determined by which Sender Profile your request is **authenticated as**. If your API key belongs to your organization or to a different profile, Sent routes the message with that identity's default routing, which may select a shared short code. ### Solution Send the message as the Sender Profile that owns the dedicated number. There are two ways to do this: **Use an API key that belongs to the profile.** API keys are scoped to the profile that is active when you create them. Switch to the target profile with the profile selector, then create or copy a key under **Dashboard → API Keys**. Requests made with this key always send as that profile. **Use your organization API key with the `x-profile-id` header.** Organization keys can act on behalf of any profile in the organization by passing the profile's ID as a request header: ```bash curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: YOUR_ORG_API_KEY" \ -H "x-profile-id: 7ba7b820-9dad-11d1-80b4-00c04fd430c8" \ -H "Content-Type: application/json" \ -d '{ "to": ["+1234567890"], "channel": ["sms"], "template": { "name": "order_confirmation" } }' ``` You can find each profile's ID in **Dashboard → Profiles**. Every profile card displays its ID with a copy button. **Sending from the dashboard instead?** Select the correct Sender Profile with the profile selector before sending. The active profile determines which number or sender ID is used for outbound messages. The `x-profile-id` value must be the profile's UUID. The API returns `400` for a malformed ID, `404` if the profile does not belong to your organization, and `403` if you send the header with a profile-scoped key. Only organization API keys can act on behalf of profiles. --- ## Sending SMS Only When WhatsApp Is Connected You have connected a WhatsApp Business Account during onboarding, but you want all messages sent exclusively via SMS, either because you have a separate WhatsApp integration elsewhere, or because you do not want Sent's channel selection logic to attempt WhatsApp delivery. ### Why This Happens By default, when a WhatsApp Business Account is connected to your Sent account, Sent will attempt WhatsApp delivery for recipients with WhatsApp capability. If your architecture already handles WhatsApp through another system, this can cause duplicate delivery attempts or route messages through a channel you are not expecting. If your connected WABA shows as unverified or restricted in Meta Business Manager, this status can affect Sent's channel routing, even if you only need SMS. ### Solution Contact Sent support to enable **SMS-only mode** on your account. This is a backend configuration that overrides channel selection and forces all outbound messages to route as SMS, regardless of whether a WABA is connected or what status it shows. Once SMS-only mode is enabled: - Messages are always delivered via SMS, regardless of WABA connection state - You do not need to disconnect your WhatsApp Business Account - WABA verification status has no impact on SMS delivery SMS-only mode is useful if you are using Sent specifically for SMS in markets where your previous provider lacked coverage, while maintaining a separate WhatsApp integration for other use cases. --- ## WhatsApp-to-SMS Failover Not Triggering You configured WhatsApp as the primary channel with SMS as the fallback, but every message is being sent via SMS: the WhatsApp attempt never seems to happen. ### Common Causes **WhatsApp template not approved.** Failover logic evaluates whether the primary channel can deliver before attempting it. If the specified template is not approved for WhatsApp, Sent skips the WhatsApp attempt entirely and falls through to SMS. **WhatsApp Business Account not connected.** If your WhatsApp Business Account is disconnected or was never fully set up, Sent cannot send on the WhatsApp channel. Verify your connection status under **Dashboard → Channels → WhatsApp**. **Recipient number not recognized as WhatsApp-capable.** Sent performs a channel capability check before attempting delivery. If the recipient's number is not detected as having WhatsApp, the system will immediately fall back to SMS without attempting WhatsApp delivery. ### How to Debug Open the message in **Dashboard → Activities** and look for two key fields in the message detail: - `channel_attempted`: shows which channel Sent tried first (should be `whatsapp` if failover is configured correctly). - `fallback_reason`: explains why the system fell back to SMS. Common values include `template_not_approved`, `channel_not_connected`, and `recipient_not_capable`. If `channel_attempted` shows `sms` from the start (rather than `whatsapp`), the failover logic was bypassed, which points to a configuration issue on the WhatsApp side. ### Checklist - Confirm the template is approved for WhatsApp in **Dashboard → Templates**. - Confirm WhatsApp Business Account is connected and active in **Dashboard → Channels**. - Confirm the recipient's number is a valid mobile number (landlines cannot receive WhatsApp messages). - Confirm your API request lets Sent choose the channel. There is no `fallback` field in the v3 API. Omit `channel` or send `"channel": ["sent"]` (auto-detect) so routing can attempt WhatsApp and fall back to SMS. Pinning `"channel": ["whatsapp"]` restricts delivery to WhatsApp only, and listing multiple channels such as `"channel": ["whatsapp", "sms"]` broadcasts a separate message on each channel instead of falling back. --- ## Invalid or Missing API Key (AUTH_002) If your API request returns the following error, the API key is either missing, malformed, or no longer valid: ```json { "success": false, "error": { "code": "AUTH_002", "message": "Invalid or missing API key" } } ``` ### Solutions **Use the correct header.** Sent uses the `x-api-key` header, not `Authorization: Bearer`. This is the most common mistake when migrating from other APIs. ```bash # Incorrect curl -H "Authorization: Bearer YOUR_API_KEY_HERE" https://api.sent.dm/v3/messages # Correct curl -H "x-api-key: YOUR_API_KEY_HERE" https://api.sent.dm/v3/messages ``` **Copy the key directly from the dashboard.** Navigate to **Dashboard → API Keys** and use the copy button to avoid transcription errors. Ensure there is no trailing whitespace or newline character. These are invisible but will cause authentication to fail. **Check if the key was regenerated.** If someone on your team regenerated the API key, the previous key is immediately invalidated. The Activities log does not record authentication failures, so you will not see the failed request there, only the `AUTH_002` response from the API. **Verify the key matches your environment.** If you have separate keys for production and sandbox, ensure you are using the correct one for the environment you are targeting. --- ## Sandbox Mode: What Gets Delivered and How to Inspect It When testing your integration in sandbox mode, messages are not actually delivered to recipients and no credit is consumed. Understanding exactly what does happen helps you write reliable tests. ### What sandbox mode does - The API processes the request and returns a success response with a real message ID, identical to a live send. - The message and its status appear in **Dashboard → Activities**. - Webhooks fire with status updates, just as they would in production. - No SMS or WhatsApp message is sent to the recipient's device. Pass `"sandbox": true` in your send request to enable it: ```json { "to": ["+1234567890"], "template": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" }, "channel": ["sms"], "sandbox": true } ``` ### Retrieving template variables (for example, OTP codes) in sandbox A common testing scenario is verifying that your OTP or other variable-content template was constructed correctly, without needing to receive a real message. You can do this by calling the get-message-status endpoint with the message ID returned from the send call. The response includes the template variables submitted with that message, so you can confirm the correct code or value was passed. ```bash GET /v3/messages/{message_id} ``` This works in sandbox mode. The message ID in the send response is real and queryable. ### When to switch to live mode Use sandbox for all logic testing: template variable substitution, webhook handling, status polling, and error path coverage. Switch to live mode only when you need to verify the actual end-to-end recipient experience, for example, confirming that an OTP code renders correctly on a specific device or carrier. Sending real messages in development environments can accumulate significant cost, especially for destinations that carry higher per-message rates. Sandbox mode eliminates this cost without changing your integration code. --- ## RCS Messages Not Delivered If RCS messages are failing or always falling back to SMS, work through this checklist: **RCS sender not approved yet**: RCS requires a one-time approval process with carriers before messages can be sent. If your RCS Agent is still pending, messages that pin RCS only (`"channel": ["rcs"]`) will fail. Check your RCS Agent status in **Dashboard → Channels → RCS**. **Recipient device or carrier does not support RCS**: RCS is only available on Android devices using Google Messages where the carrier supports it. Sent automatically falls back to SMS when RCS is unavailable. To allow this, let Sent choose the channel: omit `channel` or send `"channel": ["sent"]` (auto-detect). Listing multiple channels such as `"channel": ["rcs", "sms"]` does not configure a fallback: it broadcasts a separate message on each channel. **Template not approved for RCS**: Like WhatsApp, RCS templates must be approved before sending. Verify template status in **Dashboard → Templates**. **RCS Agent not set up**: If your account has no approved RCS Agent, RCS messages will fail. Contact Sent to initiate the onboarding process: RCS setup is not self-service. The `channel_attempted` and `fallback_reason` fields in **Dashboard → Activities** show exactly which channel was tried and why fallback was triggered (useful for diagnosing RCS-specific delivery issues). ## Still Not Resolved? If none of the preceding scenarios match your issue: 1. **Collect your diagnostic information**: message ID, timestamp, recipient number (redacted if needed), the exact API request payload (with sensitive values replaced), and the full error response. 2. **Check the [Sent status page](https://status.sent.dm)** for any ongoing incidents that could affect delivery. 3. **Contact [developer support](/start/reference-guides/support)** with the information from step 1. Including these details upfront significantly reduces resolution time. ## Related Guides - [WhatsApp Template Issues](/troubleshooting/template-issues): if messages fail due to template approval, categorization, or parameter problems - [Phone Number & Sender ID Issues](/troubleshooting/phone-numbers): if messages are sending from the wrong number or you need a different sender ID - [Compliance & 10DLC Issues](/troubleshooting/compliance): if SMS delivery is blocked by carrier restrictions or 10DLC registration issues --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/phone-numbers.txt TITLE: Phone Number & Sender ID Issues ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/phone-numbers.txt Troubleshoot common problems with phone number availability, sender IDs, regional number provisioning, and messages sending from the wrong number. # Phone Number & Sender ID Issues This guide covers the most common phone number and sender ID problems reported by Sent users, from number provisioning issues to messages sending from an unexpected number. Phone number availability varies by region and demand. If you need a number for a specific country, contact **support@sent.dm** and we can check availability or suggest alternatives. ## "No phone numbers available" When Choosing a Sender Number ### Symptoms On the **Choose Your Sender Number** step of the Sender Profile wizard (**Dashboard → Profiles**), the phone number dropdown displays: > No phone numbers available. Please refresh to try again. ### Cause This typically means one of the following: - **Regional inventory is temporarily depleted.** Sent provisions numbers from carrier partners, and stock for certain area codes or countries can run low during periods of high demand. - **Numbers have not yet been provisioned for your account.** There is a brief delay between account approval and number allocation. This does **not** indicate a problem with your compliance or KYC approval. Number provisioning is a separate step that occurs after your account has been approved. ### Solution 1. **Click the "Refresh" button** next to the dropdown. In many cases, numbers become available within seconds as inventory updates. 2. **Wait a few minutes and try again.** If the inventory for your region is temporarily low, new numbers are restocked frequently. 3. **Contact support** at [support@sent.dm](mailto:support@sent.dm) if the issue persists after several attempts. The team can provision numbers for your account manually or suggest an alternative region. ## Specific Country Numbers Not Available ### Symptoms You need a local phone number for a specific country (for example, Nigeria, Canada/Quebec, Russia, Kazakhstan, Spain), but no numbers for that region appear in the dashboard. ### Cause Sent's phone number inventory depends on carrier partnerships that vary by country. Not every country has local numbers available at all times, and some regions have stricter regulatory requirements that limit number availability. ### Solution **For WhatsApp messaging:** A US-based number works globally. WhatsApp routes messages based on the recipient's WhatsApp account, not the sender's country code. You do not need a local number to reach international recipients over WhatsApp. **For SMS messaging:** A US number can often be used to send internationally. However, depending on the destination country's regulations, your message may arrive displaying an alphanumeric sender ID rather than your numeric phone number. This is normal carrier behaviour and does not affect deliverability. **Bring your own number:** In some cases, you can port an existing phone number into Sent. Contact support to discuss eligibility and the porting process. For country-specific number availability, reach out to [support@sent.dm](mailto:support@sent.dm) with the country and use case. The team can confirm what is available or recommend the best sending strategy for your target region. ## Alphanumeric Sender ID vs Phone Number ### Overview When sending SMS in many countries outside the United States, you have the option to use an **alphanumeric sender ID** (for example, "MyBrand") instead of a phone number. This is the text that appears as the sender name on the recipient's device. ### Key differences | Feature | Alphanumeric Sender ID | Dedicated Phone Number | |---|---|---| | **Setup speed** | Generally faster | May require provisioning time | | **Replies** | Recipients cannot reply directly | Supports two-way messaging | | **US/Canada support** | Not supported (numeric sender required) | Fully supported | | **Branding** | Displays your brand name | Displays a phone number | | **Country coverage** | Varies by country regulations | Varies by carrier inventory | ### When to use each - **Use an alphanumeric sender ID** when you are sending one-way notifications (OTP codes, order confirmations, alerts) and want brand recognition in the sender field. - **Use a dedicated phone number** when you need two-way messaging, are sending to the US or Canada, or want a consistent numeric identity. ### Setup Alphanumeric sender IDs are configured during **Sender Profile** setup in the Sent dashboard. Navigate to **Dashboard → Profiles** to create or edit a Sender Profile with your preferred sender ID. Not all countries support alphanumeric sender IDs. The US and Canada require a numeric sender (phone number or short code). Check the destination country's regulations before relying on an alphanumeric sender. ## Messages Sending from the Wrong Number You configured a Sender Profile with a dedicated phone number, but outbound messages arrive from a short code (for example, "10907") or a different number than expected. This happens when the request is authenticated as the wrong Sender Profile. See [Messages Routing to Wrong Sender Number](/troubleshooting/messages-not-delivered#messages-routing-to-wrong-sender-number) for the cause and the fix for both API and dashboard sends. ## Sender ID Registration for International Markets ### Overview Some countries require sender IDs to be pre-registered before SMS can be delivered. Sending without a registered sender ID in these markets results in messages being silently filtered or blocked at the carrier level. The API will accept your request, but the message will not reach the recipient. ### Markets that require registration Registration requirements vary by country and change as local regulations evolve. As a general guide: - **African markets**: many countries including Rwanda, Ghana, Kenya, Uganda, and Nigeria require sender ID registration. Without registration, alphanumeric sender IDs may be blocked entirely. - **Other markets with strict requirements**: India, the Philippines, and several Southeast Asian countries also require pre-registration. ### Markets where generic alphanumeric sender IDs work without registration Major European markets, including France, Germany, Spain, and the UK, generally support alphanumeric sender IDs without prior registration. If you are expanding into Europe, you can typically start sending immediately using a generic sender ID. ### How to register Sent's compliance team handles sender ID registration on your behalf. The process varies by country and typically requires documentation such as your business registration, a description of your messaging use case, and sample message content. To begin registration: 1. Contact [support@sent.dm](mailto:support@sent.dm) and list the countries you need registered. 2. The Sent compliance team will provide the specific documents required for each market. 3. Submit the requested documents. Registration timelines vary: some markets complete in a few days, others can take several weeks depending on local regulators. If you are expanding into multiple markets rapidly, let the compliance team know your priority countries upfront. Registration can be initiated in parallel for multiple markets. Do not rely on unregistered sending in markets that require registration, even temporarily. Carrier-level blocks applied during unregistered sending can sometimes affect subsequent registered traffic. Starting the registration process before you begin sending is strongly recommended. ## Still Having Issues? If none of the preceding solutions resolve your problem: - Include your **account ID**, the **country** you are trying to provision a number for, and any **error messages** you see in the dashboard. - Contact [support@sent.dm](mailto:support@sent.dm) with these details for faster resolution. ## Related Guides - [Messages Not Delivered](/troubleshooting/messages-not-delivered): if your number is configured but messages are not being delivered - [Compliance & 10DLC Issues](/troubleshooting/compliance): for 10DLC registration and country-specific compliance that affects number usage - [Account Activation & KYC Review](/troubleshooting/account-activation): if you cannot access phone number provisioning because your account is still under review --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/template-issues.txt TITLE: WhatsApp Template Issues ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/template-issues.txt Troubleshoot template creation errors, approval delays, categorization problems, and parameter mismatches for WhatsApp messaging on Sent # WhatsApp Template Issues WhatsApp messages sent through the Sent platform require pre-approved templates. This guide covers the most common issues developers run into when creating, submitting, and using templates, along with solutions for each. WhatsApp templates require Meta approval before they can be used. SMS templates on Sent do not require external approval. If you need to send messages immediately, consider using SMS while your WhatsApp template is under review. ## "Failed to save template" / "Failed to submit template for review" **Symptoms:** When creating a template in the dashboard and clicking Save or Submit, you see a generic error: > Failed to save template or > Failed to submit template for review No additional detail is provided about what went wrong. **Common causes:** - **WhatsApp Business Account not connected.** Your Meta Business Account must be fully linked to Sent before you can create WhatsApp templates. Go to **Dashboard → Settings** and verify that your Meta Business Account shows as connected. - **Template body exceeds character limits.** WhatsApp enforces a maximum of **1024 characters** for the template body. Headers and footers have their own separate limits. - **Prohibited content or formatting.** Templates that contain content violating Meta's commerce or messaging policies will be rejected at submission time. This includes certain restricted industries, misleading content, or unsupported formatting. - **Application permission issue.** If the error includes the phrase "Application does not have permission for this action," see the [dedicated section below](#application-does-not-have-permission-for-this-action). **Solutions:** 1. Open **Dashboard → Settings** and confirm your WhatsApp Business Account is connected and showing a healthy status. 2. Check your template body length. If you are close to the limit, trim the content or split it across multiple messages. 3. Review your template against [Meta's template guidelines](https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview) for prohibited content. 4. Try creating a **text-only template** with no variables, header, or buttons. If this succeeds, add complexity incrementally to isolate what is causing the failure. 5. If the error mentions "permission," see the next section. ## "Application does not have permission for this action" **Symptoms:** When submitting a template for review, you receive: > Failed to submit template for review: Application does not have permission for this action **What's happening:** The Meta Business Account integration with Sent does not have the required permissions to manage templates on your behalf. This typically happens when permissions were not fully granted during the initial WhatsApp Business Account connection flow, or when Meta has revoked permissions due to a policy change. **Solutions:** 1. Go to **Dashboard → Settings** and disconnect your WhatsApp Business Account. 2. Reconnect the account by going through the Facebook OAuth flow again. During the connection process, ensure you **grant all requested permissions** when prompted. Do not skip or decline any permission scopes. 3. After reconnecting, try submitting your template again. 4. If the issue persists after disconnecting and reconnecting, contact [support@sent.dm](mailto:support@sent.dm). Sent support may need to re-authorize the integration, or there may be a configuration issue with your Meta Business Account that requires manual intervention. When reconnecting your WhatsApp Business Account, make sure you are logged into the correct Facebook account that owns or administers the Meta Business Account. Connecting with a different Facebook account can result in permission errors. ## Template stuck in PENDING approval **Symptoms:** You submitted a template for review, and it has been in "Pending" status for an extended period with no updates. **What's happening:** WhatsApp templates go through Meta's review process before they can be used to send messages. This is a Meta-side process that Sent does not control. **Typical timeline:** - Most templates are reviewed within **24-48 hours**. - During peak periods or for templates in certain categories, reviews can take longer. - Templates with media (images, videos, documents) may take slightly longer than text-only templates. **What to do:** 1. **If pending for less than 48 hours:** Wait. This is within the normal review window. 2. **If pending for more than 48 hours:** - Log into [Meta Business Manager](https://business.facebook.com/) directly and navigate to your WhatsApp account's message templates. Meta sometimes provides additional context or action items there that are not surfaced in the Sent dashboard. - Contact [support@sent.dm](mailto:support@sent.dm) and ask Sent support to check the template status. Include your template name and the approximate date you submitted it. 3. **If you need to send messages now:** Use an existing approved SMS template. SMS templates on Sent do not require external approval and can be used immediately after creation. ## Template categorized incorrectly by Meta **Symptoms:** You submitted a template as **Utility** (transactional), but Meta re-categorized it as **Marketing**. Alternatively, you expected one category but received another after approval. **Why this matters:** - **Marketing templates** are subject to stricter delivery rules, including per-user frequency caps. If a recipient has received too many marketing messages recently, your message may not be delivered. - **Utility templates** (order confirmations, shipping updates, OTP codes, account alerts) have more lenient delivery rules because they contain information the recipient is expecting. - **Authentication templates** (OTP codes, verification) have the highest delivery priority. **What to do:** 1. Review your template content. Meta categorizes templates based on the language and intent of the content, not the category you select during creation. Common triggers for marketing categorization include: - Promotional language ("Get 20% off," "Limited time offer," "Shop now") - Upselling or cross-selling content - General announcements not tied to a specific user action 2. **Appeal through Meta Business Manager.** Log into [Meta Business Manager](https://business.facebook.com/), navigate to your template, and submit a category appeal if you believe the categorization is incorrect. 3. **Contact Sent support.** The team can also submit an appeal on your behalf. Send an email to [support@sent.dm](mailto:support@sent.dm) with the template name and your reasoning for why it should be categorized differently. **To avoid miscategorization in the first place:** - Do not include any promotional language in utility or authentication templates. - Keep the content focused on the specific transaction or event that triggered the message (for example, "Your order #12345 has shipped" rather than "Your order has shipped. Check out new arrivals"). - Avoid including links to product pages, promotional landing pages, or anything not directly related to the transaction. ## Template variables / parameters mismatch **Symptoms:** Your template is approved and visible in the dashboard, but sends fail with a 400 error that reports a missing or invalid variable. **What's happening:** Sent templates use **named variables** such as `{{customerName}}` or `{{orderNumber}}`. When you send a message using a template, you provide a `parameters` **object** whose keys match the variable names defined in the template. The rule: every variable name defined in the template must be present in the `parameters` object. A missing name causes a 400 error (`VALIDATION_004`) listing which names are missing. Extra keys are ignored, never rejected. **Common mistakes:** - **Missing variable names.** Every variable defined in any section of the template (header, body, buttons) must appear as a key in `parameters`. Order does not matter. - **Passing an array instead of an object.** `parameters` is an object keyed by variable name. A positional array like `['Alex', 'ORD-98765']` fails request validation before the message is accepted. - **Invalid parameter values.** Values cannot contain newlines, tabs, or five or more consecutive spaces. Sent rejects these with a 400 error (`VALIDATION_008`) at send time, mirroring WhatsApp's own constraint on parameter text. **Example:** If your approved template body is: ``` Hi {{customerName}}, your order {{orderNumber}} has been shipped and will arrive by {{deliveryDate}}. ``` Your API call should include: ```typescript await client.messages.send({ to: ['+14155552671'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', parameters: { customerName: 'Alex', orderNumber: 'ORD-98765', deliveryDate: 'March 25' } } }); ``` **Debugging tips:** 1. Open the template in the dashboard and note every variable name across all sections (header, body, buttons). Each name must appear as a key in your `parameters` object. 2. Read the 400 response body: the error details list each missing variable by name. 3. Test with hardcoded string values first before using dynamic data from your app. To avoid these issues in future templates, follow the [template best practices](/start/guides/working-with-templates#template-best-practices) in the templates guide. ## Still need help? If you have worked through the preceding steps and your template issue is not resolved, contact the Sent support team: - **email:** [support@sent.dm](mailto:support@sent.dm) - **Include:** Your template name, the error message you are seeing (exact text or screenshot), and whether this is a new template or one that was previously working. - **Response time:** The support team typically responds within 1 business day. ## Related Guides - [Messages Not Delivered](/troubleshooting/messages-not-delivered): if your template is approved but messages still fail to deliver - [WhatsApp Setup](/troubleshooting/whatsapp-setup): if you're unable to create templates because your WhatsApp Business Account is not connected - [Compliance & 10DLC Issues](/troubleshooting/compliance): for SMS-specific compliance and A2P registration requirements --- ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/whatsapp-delivery-errors.txt TITLE: Troubleshoot WhatsApp Delivery Failures by Error Code ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/whatsapp-delivery-errors.txt Diagnose failed WhatsApp messages by error code, from undeliverable recipients to invalid template names and parameters, and see when Sent falls back to SMS. # Troubleshoot WhatsApp Delivery Failures by Error Code This guide shows you how to diagnose WhatsApp messages that fail after the API accepted them, and how to resolve each of the per-message error codes that WhatsApp sends produce. It assumes your WhatsApp channel is already connected and sending; for connection and onboarding problems, see [WhatsApp Onboarding & Business Account Connection](/troubleshooting/whatsapp-setup). ## Rule Out a Synchronous API Error First A rejected API call and a failed delivery are different problems: - **Synchronous API errors** come back in the HTTP response. The call returns `4xx` with an envelope code such as `AUTH_002` or `VALIDATION_002`. Fix the request itself; refer to the [error handling reference](/reference/api/errors) for the envelope format and the full code list. - **Asynchronous delivery failures** happen after `POST /v3/messages` responds `202 Accepted`. Each message is processed and finalized on its own, so a message can fail minutes after a successful API call. These failures carry the `ERR_*` codes covered in this guide, which never appear in the HTTP response. If your API call returned `2xx` but the message shows as failed, you are debugging an asynchronous delivery failure. Continue below. ## Locate the Failure A failed delivery surfaces in four places: - `GET /v3/messages/{id}`: the `status` field reads `FAILED`, and `channel` shows the channel of the most recent attempt. - `GET /v3/messages/{id}/activities`: the lifecycle log ends in a `FAILED` activity. - The [`message.failed` webhook event](/start/webhooks/event-types) pushes the failure to your endpoint as it happens. - **Dashboard → Activities** shows the same timeline for each message. ```bash curl https://api.sent.dm/v3/messages/8ba7b830-9dad-11d1-80b4-00c04fd430c8/activities \ -H "x-api-key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "activities": [ { "status": "FAILED", "description": "Message updated to FAILED", "from": "+15551234567", "timestamp": "2026-07-24T15:00:20Z", "price": null, "active_contact_price": null } ] } } ``` Activity descriptions and webhook payloads report the `FAILED` status but do not currently include the `ERR_*` code itself. To get the exact code for a specific message, [contact support](/start/reference-guides/support) with the message ID. Each section below also describes the behavior you can observe (for example, an automatic SMS re-send) so you can narrow down the code without it. ## Match the Code to a Fix | Error code | Raised by | Meta error | Falls back to SMS | Meaning | |------------|-----------|------------|-------------------|---------| | [`ERR_MESSAGE_UNDELIVERABLE`](#err_message_undeliverable) | Meta | 131026 | Yes, automatic | Meta accepted the message, then reported it undeliverable | | [`ERR_TEMPLATE_PARAMS_INVALID`](#err_template_params_invalid) | Sent or Meta | 100 | No | Template variables missing, malformed, or rejected by Meta | | [`ERR_TEMPLATE_NAME_INVALID`](#err_template_name_invalid) | Meta | - | No | No template with that name exists for the language sent | | [`ERR_RECIPIENT_NOT_REGISTERED`](#err_recipient_not_registered) | Meta | 133016 | Yes, automatic | The recipient's number is not on WhatsApp | | [`ERR_PROVIDER_ERROR`](#err_provider_error) | Meta or carrier | - | No | Unclassified provider error | | [`ERR_ECOSYSTEM_ENGAGEMENT`](#err_ecosystem_engagement) | Meta | 131049 | Yes, automatic | Meta anti-spam restriction | ## ERR_MESSAGE_UNDELIVERABLE Meta accepted the message (the send call succeeded and returned a WhatsApp message ID), then reported it undeliverable in a later delivery status callback. Sent maps Meta's undeliverable failure, WhatsApp error 131026, to this code. It is a recipient-side failure: the recipient cannot currently receive WhatsApp messages from your number. Sent handles it automatically: 1. The WhatsApp attempt is recorded as `FAILED` and a `message.failed` webhook fires with `"channel": "whatsapp"`. 2. WhatsApp is paused for that recipient for 30 days: routing skips WhatsApp for them until the pause expires. 3. The same message is re-sent through SMS routing. Fresh lifecycle events arrive for the same `message_id` with `"channel": "sms"`, and the message's `channel` field changes to `sms`. You do not need to fix anything per message: watch for the SMS attempt's outcome (`message.delivered` with `"channel": "sms"`). If sends to a recipient repeatedly end here, treat that number as unreachable on WhatsApp and message them over SMS. The 30-day WhatsApp pause expires on its own. ## ERR_TEMPLATE_PARAMS_INVALID The variables submitted with the send do not satisfy the template. Both Sent and Meta raise this code: - **Sent's send pipeline** blocks the message when a required template variable is missing, a value contains new-line or tab characters or more than 4 consecutive spaces (Meta's parameter text rules), or a value fails the variable's validation pattern. - **Meta** rejects the dispatch with error 100 (invalid parameter) when the payload does not match the approved template, for example the wrong variable count, or a plain string where the template expects a currency or date-time object. The message stays `FAILED`; there is no SMS fallback and no automatic retry. To resolve: 1. Open the template in **Dashboard → Templates** and compare its placeholders against the variables in your request: supply every placeholder, and remove extras. 2. Strip new-lines, tabs, and long runs of spaces from variable values. 3. Send a corrected message. If the template itself is the problem (rejected, miscategorized, or stuck in review), see [WhatsApp Template Issues](/troubleshooting/template-issues). ## ERR_TEMPLATE_NAME_INVALID Meta rejected the send because no template with that name exists on the WhatsApp Business Account for the language sent. Meta's error message reads `template name does not exist in the translation`. To resolve: 1. Confirm the template exists and shows **Approved** in **Dashboard → Templates**. 2. If you manage templates directly in Meta's WhatsApp Manager, confirm the template was not renamed or deleted there, and that it exists in the language you are sending. 3. Send a corrected message. The failed message stays `FAILED`; there is no SMS fallback and no automatic retry. ## ERR_RECIPIENT_NOT_REGISTERED The recipient's phone number is not registered on WhatsApp. Sent maps Meta error 133016, and provider messages stating the number is not a WhatsApp user, to this code. For a genuine unregistered recipient, Sent reacts the same way as for [`ERR_MESSAGE_UNDELIVERABLE`](#err_message_undeliverable): WhatsApp is paused for that recipient for 30 days and the message is re-sent through SMS routing under the same `message_id`. No action is needed beyond confirming the SMS attempt delivered. If your own WhatsApp number is not fully connected to a WhatsApp Business Account, Meta can report every send with this code even though the problem is on your side, not the recipient's. If all of your WhatsApp sends fail this way, check the connection under **Dashboard → Channels → WhatsApp** and work through [WhatsApp Onboarding & Business Account Connection](/troubleshooting/whatsapp-setup) before assuming your recipients lack WhatsApp. ## ERR_PROVIDER_ERROR The catch-all code: the provider returned an error that Sent could not map to any specific code. It appears on WhatsApp and on SMS carriers alike. Unclassified failures deliberately do not trigger an automatic re-send, so the message stays `FAILED`. To resolve: 1. Check the [Sent status page](https://status.sent.dm) for an ongoing incident. 2. [Contact support](/start/reference-guides/support) with the message ID to get the raw provider response. 3. If the cause was transient, send the message again. ## ERR_ECOSYSTEM_ENGAGEMENT Meta's anti-spam restriction, WhatsApp error 131049: Meta declined to deliver the message because its systems flagged the send as spam-like. Sent treats it as a recipient-side failure, so the message is automatically re-sent through SMS routing and WhatsApp is paused for that recipient for 30 days. For the causes and prevention steps (the 24-hour conversation window, template reclassification, send frequency), see [ERR_ECOSYSTEM_ENGAGEMENT in Messages Not Delivered](/troubleshooting/messages-not-delivered#whatsapp-err_ecosystem_engagement--error-131049). ## When Failures Fall Back to SMS Two separate mechanisms can move a WhatsApp send to SMS: - **After Meta accepts, on a recipient-side failure.** `ERR_MESSAGE_UNDELIVERABLE`, `ERR_RECIPIENT_NOT_REGISTERED`, and `ERR_ECOSYSTEM_ENGAGEMENT` trigger an automatic re-send of the same message over SMS, plus the 30-day per-recipient WhatsApp pause. You observe a `message.failed` event with `"channel": "whatsapp"` followed by new lifecycle events for the same `message_id` with `"channel": "sms"`. - **At send time, before Meta accepts.** If the WhatsApp send call itself fails and your routing allows fallback, Sent immediately tries the next eligible route in the same send. See [WhatsApp-to-SMS failover](/troubleshooting/messages-not-delivered#whatsapp-to-sms-failover-not-triggering) for debugging that path. Template and unclassified errors (`ERR_TEMPLATE_PARAMS_INVALID`, `ERR_TEMPLATE_NAME_INVALID`, `ERR_PROVIDER_ERROR`) never fall back: the message stays `FAILED`, and you must fix the cause and send a new message. ## Verify the Resolution After applying a fix, send the message again and confirm delivery: - `GET /v3/messages/{id}` returns `"status": "DELIVERED"`, or a `message.delivered` webhook arrives. - For the automatic-fallback codes, confirm the SMS attempt delivered: the terminal event carries `"channel": "sms"`. ## Related - [Messages Not Delivered](/troubleshooting/messages-not-delivered): API-vs-dashboard mismatches, country-specific SMS filtering, and sender routing issues - [WhatsApp Template Issues](/troubleshooting/template-issues): template approval, categorization, and rejection problems - [Error Handling](/reference/api/errors): the API error envelope, HTTP status codes, and the full send-time `ERR_*` code table - [Message Status Tracking](/start/guides/message-status-tracking): tracking delivery status by webhook, polling, and dashboard ================================================================================ SOURCE: https://docs.sent.dm/llms/troubleshooting/whatsapp-setup.txt TITLE: WhatsApp Onboarding & Business Account Connection ================================================================================ URL: https://docs.sent.dm/llms/troubleshooting/whatsapp-setup.txt Troubleshoot WhatsApp Business Account connection errors, phone number conflicts, Meta verification issues, and onboarding requirements # WhatsApp Onboarding & Business Account Connection Setting up WhatsApp as a messaging channel on Sent requires connecting a Meta (Facebook) Business Account and a WhatsApp Business Account. Because this process involves multiple systems (Sent, Meta Business Manager, and WhatsApp), there are several points where things can go wrong. This guide covers the most common issues and how to resolve them. If your channel is already connected and individual messages fail after you send them, see [Troubleshoot WhatsApp Delivery Failures by Error Code](/troubleshooting/whatsapp-delivery-errors) instead. WhatsApp Business API access is managed by Meta. Many WhatsApp connection issues originate from Meta's side, not Sent's. If you're having trouble connecting, start by verifying your Meta Business Account status at [business.facebook.com](https://business.facebook.com). ## Prerequisites for WhatsApp setup Before starting the WhatsApp onboarding flow in the Sent dashboard, confirm that you have the following: - **Meta Business Account (verified)**: Created and verified at [business.facebook.com](https://business.facebook.com). Verification includes submitting your business documents to Meta and having them approved. - **Facebook personal account**: Required to manage the Meta Business Account. This is the account you will use to log in during the connection flow. - **Phone number not already linked to another WhatsApp Business Account**: A phone number can only be associated with one WhatsApp Business Account at a time. If your number is currently linked elsewhere, you must remove it first. - **Sent account with KYC approved**: Your Sent account must have passed compliance review before you can configure channels. See [Account Activation & KYC Review](/troubleshooting/account-activation) if your account is still under review. If any of these prerequisites are not met, the onboarding flow will fail at various stages. Address them before proceeding. ## "The information could not be verified" connection error **Symptoms:** During step 2 of the WhatsApp onboarding flow, a Facebook popup opens for connecting your WhatsApp Business Account. After selecting your WA account and confirming authorization, you see: > The information could not be verified. Please repeat the verification process or cancel the registration. Failed to share the WhatsApp Business Account with partners. **Common causes:** - Your Meta Business Account has not completed Meta's own business verification process. - Two-factor authentication on your Facebook account is interfering with the OAuth flow. - Your browser is blocking popups or third-party cookies required by the Facebook login window. - You are using a browser with known compatibility issues with Facebook OAuth (Safari in particular). **Solutions:** 1. **Verify your Meta Business Account.** Log in to [Meta Business Manager](https://business.facebook.com) and navigate to **Settings → Business Info → Business Verification**. If verification is not complete, follow Meta's instructions to submit the required documents. This process can take several days on Meta's side. 2. **Disable popup blockers temporarily.** The connection flow opens a Facebook popup window. If your browser blocks it, the authorization cannot complete. Allow popups from `sent.dm` and `facebook.com` for the duration of the setup. 3. **Use Chrome instead of Safari.** Safari has known issues with Facebook's OAuth flow due to its Intelligent Tracking Prevention (ITP) feature, which blocks third-party cookies. Chrome is the recommended browser for this step. 4. **Clear Facebook session cookies.** If you have multiple Facebook accounts or recently changed your password, stale session data can cause verification failures. Clear cookies for `facebook.com` and `business.facebook.com`, then log in again before retrying. 5. **Complete Meta Business Verification first.** If you created a new Meta Business Account specifically for Sent, it may not yet be verified. Meta requires business verification before you can share your WhatsApp Business Account with third-party partners like Sent. This is a Meta requirement and cannot be bypassed. Meta Business Verification is separate from Sent's KYC process. You need to complete both: Sent's KYC for your Sent account, and Meta's Business Verification for your Meta Business Account. ## "This phone number is already used and linked to a WhatsApp account" **Symptoms:** During WhatsApp channel setup, you receive an error indicating that the phone number you are trying to use is already linked to another WhatsApp Business Account. **Why this happens:** A phone number can only be connected to **one** WhatsApp Business Account at a time. This is a Meta platform limitation. If the number was previously registered with a different WhatsApp Business Account (whether through another provider, a previous Sent account, or the standard WhatsApp Business app), it must be removed from that account before it can be linked to a new one. **Solutions:** 1. **Remove the number from the previous WhatsApp Business Account.** Log in to [Meta Business Manager](https://business.facebook.com), navigate to the WhatsApp Business Account that currently holds the number, and remove it. After removal, wait a few minutes before retrying the connection in Sent. 2. **If you don't have access to the previous account:** Contact [Meta Business Support](https://www.facebook.com/business/help) to request that the number be released. You will need to verify ownership of the number. This process may take several days. 3. **Use a different phone number.** If releasing the number is not feasible or time-sensitive, consider using a different phone number for your WhatsApp channel on Sent. If you were previously using the number with the standard WhatsApp Business app (not the API), you must delete the WhatsApp Business app account for that number before it can be used with the WhatsApp Business API through Sent. ## Meta verification code sent to Sent-provisioned number **Symptoms:** During the WhatsApp onboarding flow, Meta sends a 6-digit verification code via SMS to the phone number that was provisioned by Sent. You do not have physical access to this number and cannot receive the SMS. **Why this happens:** Meta requires phone number verification as part of the WhatsApp Business API setup. When Sent provisions a number for you, the verification step is handled automatically by Sent's integration with the carrier. **What to do:** 1. **You do not need to manually enter a verification code.** Sent handles the automated verification of provisioned numbers as part of the integration flow. If the system is working correctly, the code is received and submitted automatically. 2. **If you see a prompt asking for the code,** you may have navigated to Meta's verification page directly rather than going through the Sent dashboard. Go back to the Sent dashboard and follow the WhatsApp onboarding flow from there. The dashboard orchestrates the verification process end-to-end. 3. **If the automated verification does not complete** after several minutes, contact [support@sent.dm](mailto:support@sent.dm) with: - The phone number that was provisioned - The step in the onboarding flow where you are stuck - Any error messages displayed in the dashboard Do not attempt to verify the number manually through Meta Business Manager, as this can interfere with Sent's automated process. ## Country or number became unavailable after registration **Symptoms:** The country or phone number you originally selected during WhatsApp setup is no longer available. The country may have been removed from the available list, or no numbers are shown for that region. **Why this happens:** Phone number availability depends on carrier partnerships and regional regulations. Countries and number ranges can be added or removed over time due to: - Changes in carrier agreements - Regulatory requirements in specific regions - Temporary inventory shortages **Solutions:** 1. **Contact support for alternatives.** Send an email to [support@sent.dm](mailto:support@sent.dm) with your original number or country selection and the team can check whether alternative numbers are available or if the region is expected to return. 2. **Consider using a US number.** US phone numbers for WhatsApp work globally regardless of your business location or your recipients' locations. WhatsApp messages are delivered over the internet, so the number's country code does not affect deliverability. A US number is often the most reliable option if your preferred region is unavailable. ## Facebook account required for WhatsApp setup **Symptoms:** You are prompted to connect a Facebook account during WhatsApp channel setup and were not expecting this requirement. **Why a Facebook account is required:** The WhatsApp Business API is part of Meta's platform. All WhatsApp Business API access is managed through Meta Business Manager, which requires a Facebook account. This is a Meta requirement, not a Sent limitation. **What you need:** | Account | Purpose | Where to create | |---|---|---| | **Facebook personal account** | Used to log in to Meta Business Manager and manage business assets | [facebook.com](https://www.facebook.com) | | **Meta Business Account** | Container for your business assets including WhatsApp | [business.facebook.com](https://business.facebook.com) | | **WhatsApp Business Account** | Created during the Sent onboarding flow or in Meta Business Manager | Created automatically during setup | **If you don't have a Meta Business Account:** 1. Go to [business.facebook.com](https://business.facebook.com) and click **Create Account**. 2. Follow Meta's setup wizard to provide your business name, your name, and your business email. 3. Complete Meta's Business Verification process (required for WhatsApp Business API access). 4. Return to the Sent dashboard and start the WhatsApp onboarding flow. The Sent onboarding wizard guides you through connecting these accounts step by step. If you already have a Meta Business Account, the flow will detect it and prompt you to select it during authorization. ## Country not available in Meta payment or address forms **Symptoms:** When setting up billing or entering address details in Meta Business Manager as part of WABA setup, your country does not appear in the dropdown. Meta recommends adding a payment method to speed up WABA approval, but the form cannot be completed without the correct country selection. **Why this happens:** This is an inconsistency in Meta's platform. A country may appear in some Meta form fields (such as the VAT number entry field) but be absent from others (such as the billing address country selector). The omission is on Meta's side and is not related to your Sent account or WABA configuration. **Impact:** Without a payment method on file, Meta may take longer to approve your WABA. However, the missing country does **not** block the WABA connection itself. You can complete onboarding on Sent using a provisionally assigned phone number and add payment details later. **Solutions:** 1. **Proceed without a payment method.** Complete the WABA setup flow in the Sent dashboard as normal. A provisional number will be assigned. You can update the number and revisit payment setup once Meta resolves the issue on their end. 2. **Submit a support ticket to Meta.** Go to [Meta Business Help](https://business.facebook.com/help) and report that your country is missing from the payment setup dropdown. Reference the inconsistency (for example, country appears in VAT fields but not billing address) in your report. 3. **Check other Meta Business Manager accounts.** If you manage multiple Meta Business assets, the country dropdown behavior can differ between them. You may find a path to add billing details through an account or asset where the country does appear. A provisionally assigned number can be changed later. If you need a local number for your region once WABA is approved, contact [support@sent.dm](mailto:support@sent.dm) and the team can update it. ## "Log in with Meta" button is greyed out or pop-up does not open **Symptoms:** On the WhatsApp Integration step of onboarding, the **Log in with Meta** button is greyed out and cannot be clicked, or clicking it does nothing: the Meta authorization window never appears. **Why this happens:** The **Log in with Meta** button loads external scripts from `facebook.com` before it becomes active. Browser extensions that block ads, trackers, or third-party scripts (such as uBlock Origin, Privacy Badger, or similar tools) can silently prevent these scripts from loading, leaving the button permanently greyed out. The same extensions can also allow the button to activate but then block the Facebook authorization pop-up from opening. **Solutions:** 1. **Try a different browser without extensions.** Any browser profile without ad-blocking or privacy extensions installed will typically work. This is the fastest way to confirm whether an extension is the cause. 2. **Use an incognito or private window.** Most browsers turn off extensions in private mode by default. If the button becomes active in incognito, an extension in your normal session is the cause. 3. **Turn off your extensions temporarily.** If you prefer to stay in your regular browser, turn off ad blockers and privacy extensions one at a time until the button activates, then re-enable them after completing setup. This issue is caused entirely by the browser environment and is not related to your Sent account or Meta configuration. Once the button loads correctly in a compatible browser, the rest of the connection flow proceeds normally. ## Missing administrator permissions for WABA connection **Symptoms:** During the WABA connection flow, Meta displays a message indicating that your account does not have the required permissions to create or modify WhatsApp Business accounts. You cannot proceed past this step. **Why this happens:** Connecting a WhatsApp Business Account to Sent requires Meta Business Manager administrator permissions. If the person completing the setup is not an administrator on the Meta Business Account (or if the account was set up with limited roles), Meta blocks the authorization step. **Solutions:** 1. **Have a Meta Business Account administrator complete the connection.** Log in to [Meta Business Manager](https://business.facebook.com), navigate to **Settings → People**, and verify that the account being used to connect has administrator access. If not, ask an administrator to complete the WABA connection step in the Sent dashboard. 2. **Grant administrator permissions to your account.** If you need to complete the setup yourself, have an existing administrator grant you administrator access in Meta Business Manager, then retry the connection flow. 3. **Use a different Sent account login if needed.** Another person on your team can log in to Sent and complete the WhatsApp connection step as long as they have administrator access to the Meta Business Account. The admin requirement is enforced by Meta, not Sent. Sent cannot bypass or work around this permission check. The account used to authorize the connection must have admin rights on the Meta Business Account. ## General troubleshooting steps If you are experiencing a WhatsApp connection issue not covered in the preceding sections, try these steps in order: 1. **Check Meta Business Manager status.** Log in to [business.facebook.com](https://business.facebook.com) and verify that your Meta Business Account is active and verified. Look for any alerts or pending actions. 2. **Use Chrome in incognito mode.** This eliminates issues caused by cached data, conflicting extensions, or stale sessions. 3. **Ensure only one Facebook account is logged in.** Multiple Facebook sessions can cause the OAuth flow to authenticate with the wrong account. Log out of all Facebook accounts and log in with only the one associated with your Meta Business Account. 4. **Retry the onboarding flow from the Sent dashboard.** Navigate to the WhatsApp channel setup in your Sent dashboard and restart the connection process. Do not attempt to configure WhatsApp directly through Meta Business Manager. 5. **Check Meta's status page.** Occasionally, Meta's APIs experience outages that affect WhatsApp Business API operations. Check [metastatus.com](https://metastatus.com) for any ongoing incidents. ## Still need help? If you have worked through the preceding steps and your WhatsApp setup is not completing, contact the Sent support team: - **email:** [support@sent.dm](mailto:support@sent.dm) - **Include:** The email address on your Sent account, the step in the onboarding flow where you are stuck, any error messages (screenshots are helpful), and the browser you are using. - **Response time:** The support team typically responds within 1 business day. For other onboarding issues not related to WhatsApp, see [Account Activation & KYC Review](/troubleshooting/account-activation) or the [FAQ](/start/reference-guides/faq). ## Related Guides - [WhatsApp Template Issues](/troubleshooting/template-issues): once WhatsApp is connected, troubleshoot template creation, approval, and sending - [Troubleshoot WhatsApp Delivery Failures by Error Code](/troubleshooting/whatsapp-delivery-errors): match each `ERR_*` code to a fix when connected messages fail on send - [Messages Not Delivered](/troubleshooting/messages-not-delivered): diagnose delivery failures after your channels are configured - [Phone Number & Sender ID Issues](/troubleshooting/phone-numbers): if you need a different number or are having trouble with sender routing ---