diff --git a/examples/jvagent_app/agents/jvagent/example_agent/README.md b/examples/jvagent_app/agents/jvagent/example_agent/README.md index 974514ff..aed3dbe5 100644 --- a/examples/jvagent_app/agents/jvagent/example_agent/README.md +++ b/examples/jvagent_app/agents/jvagent/example_agent/README.md @@ -179,6 +179,34 @@ Demonstrates custom action development with: - Custom endpoints - Lifecycle hooks +### SentDM Broadcast Action + +Sends template-based SMS / WhatsApp broadcasts through the +[SentDM v3 API](https://docs.sent.dm/reference/api). + +**Required environment variables:** +- `SENTDM_API_KEY` — account API key (sent as the `x-api-key` header). +- `JVAGENT_PUBLIC_BASE_URL` — needed to auto-register a delivery-status webhook. +- `JVSPATIAL_JWT_SECRET_KEY` — needed to create the system user that owns the webhook API key. + +The action ships with `sandbox: true` in this example so calls validate but do +not actually send messages — flip to `false` once you have a real template +configured and want to broadcast for real. + +**Key admin endpoints** (`action_id` is the id of this registered action): + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/actions/{action_id}/broadcast` | Send a broadcast | +| `POST` | `/api/actions/{action_id}/webhook/register` | Force webhook reconcile | +| `GET` | `/api/actions/{action_id}/webhook` | View the registered webhook URL | +| `POST` | `/api/webhook/{action_id}?api_key=...` | Inbound SentDM webhook (registered automatically) | + +An interactive tester ships with the action at +[`jvagent/action/sentdm_broadcast/test_cli.py`](../../../../../jvagent/action/sentdm_broadcast/test_cli.py). +Full docs in the +[SentDM Broadcast README](../../../../../jvagent/action/sentdm_broadcast/README.md). + ## Usage When jvagent starts from the app directory: @@ -206,6 +234,9 @@ To create your own agent: This agent uses environment variables for configuration: - `${OPENAI_API_KEY}`: OpenAI API key for the model and embedding actions (set in `.env` file) +- `${OLLAMA_API_KEY}`: Ollama Cloud API key for the cockpit's primary engine (set in `.env` file) - `${TYPESENSE_API_KEY}`: Typesense API key for the vector store (set in `.env` file) +- `${SENTDM_API_KEY}`: SentDM API key for the broadcast action (set in `.env` file) +- `${JVAGENT_PUBLIC_BASE_URL}`: Public origin used by SentDM (and other) webhook auto-registration (set in `.env` file) See the main [jvagent README](../../../../../../README.md) for more information about environment variable resolution. diff --git a/examples/jvagent_app/agents/jvagent/example_agent/agent.yaml b/examples/jvagent_app/agents/jvagent/example_agent/agent.yaml index 34de13bf..b564317a 100644 --- a/examples/jvagent_app/agents/jvagent/example_agent/agent.yaml +++ b/examples/jvagent_app/agents/jvagent/example_agent/agent.yaml @@ -106,6 +106,37 @@ actions: model_temperature: 0.1 model_max_tokens: 8192 + # ── SentDM broadcast (SMS / WhatsApp) ──────────────────── + # Sends template-based broadcasts via the SentDM v3 API. Requires the + # SENTDM_API_KEY environment variable. For webhook auto-registration of + # delivery events, also set JVAGENT_PUBLIC_BASE_URL and + # JVSPATIAL_JWT_SECRET_KEY. Templates must already exist in your SentDM + # account (create them via the SentDM dashboard). + - action: jvagent/sentdm_broadcast_action + context: + enabled: true + description: "SentDM broadcast — template-based SMS / WhatsApp messages" + default_channels: ["sms"] + # Uncomment to set an account-wide fallback template: + # default_template_name: "order_confirmation" + # default_template_id: "" +# profile_id: "5311d08c-f79a-41b1-8ebb-1f0efaefdf8f" + sandbox: true # validate only, no real send — flip to false to go live + timeout: 30 + # Webhook tuning (used when JVAGENT_PUBLIC_BASE_URL is set) + webhook_display_name: "jvagent SentDM" + webhook_event_types: ["message"] + # webhook_event_filters: {} # omit Sent event_filters (all message.*); default includes queued + received + outbound lifecycle + webhook_retry_count: 3 + webhook_timeout_seconds: 30 + # Graph persistence — each send creates a SentDMBroadcastRecord node + # (one per recipient/channel), and the inbound webhook handler folds + # delivery events into it. Disable persist_records to skip entirely. + persist_records: true + # Sandbox validates only — no carrier send. Records are skipped unless true: + persist_sandbox_sends: true + record_event_history_limit: 25 + # ── Signup interview (local custom action) ─────────────── - action: jvagent/signup_interview_interact_action context: diff --git a/jvagent/action/sentdm_broadcast/README.md b/jvagent/action/sentdm_broadcast/README.md new file mode 100644 index 00000000..4e46d3c1 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/README.md @@ -0,0 +1,193 @@ +# SentDM Broadcast Action + +`SentDMBroadcastAction` wraps SentDM's [v3 REST API](https://docs.sent.dm/reference/api) +to send template-based SMS / WhatsApp broadcasts, read message status, list +templates, and receive delivery-status callbacks via an auto-registered webhook. + +## Required environment variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `SENTDM_API_KEY` | yes | Authenticates every call (`x-api-key` header). Create one in the SentDM dashboard. | +| `JVAGENT_PUBLIC_BASE_URL` | for webhooks | Public origin used to build the callback URL given to SentDM. | +| `JVSPATIAL_JWT_SECRET_KEY` | for webhooks | Required by jvspatial to create the system user that owns the webhook API key. | + +The send endpoint requires a **pre-existing template** in your SentDM account. +Create templates from the SentDM dashboard (or `POST /v3/templates`) before +calling `send_broadcast`. + +## Configuration + +```yaml +actions: + - action: jvagent/sentdm_broadcast_action + context: + enabled: true + default_channels: ["sms", "whatsapp"] + default_template_name: "order_confirmation" + sandbox: false +``` + +### Attributes + +| Attribute | Type | Default | Description | +| --- | --- | --- | --- | +| `api_base` | `str` | `"https://api.sent.dm"` | Base URL for the SentDM v3 API. | +| `default_channels` | `List[str]` | `["sms"]` | Channels used when a call omits `channels`. Values: `sms`, `whatsapp`, `rcs`. | +| `default_template_id` | `str` | `""` | Fallback template UUID for `send_broadcast`. | +| `default_template_name` | `str` | `""` | Fallback template name for `send_broadcast`. | +| `profile_id` | `str` | `""` | Optional `x-profile-id` value (org API keys scoping to a child profile). | +| `timeout` | `int` | `30` | HTTP timeout in seconds. | +| `sandbox` | `bool` | `false` | Default `sandbox` flag for mutating calls. | +| `webhook_display_name` | `str` | `"jvagent SentDM"` | Display name used when (re)creating the SentDM webhook. | +| `webhook_event_types` | `List[str]` | `["message"]` | Parent categories for Sent ``POST /v3/webhooks`` (``message``, ``templates``). Sub-types use ``webhook_event_filters``. | +| `webhook_event_filters` | `Dict[str, List[str]] \| null` | `null` → default ``{message: [queued, sent, delivered, read, failed, received]}`` | Sent ``event_filters``; ``{}`` means all sub-types for subscribed parents. | +| `webhook_retry_count` | `int` | `3` | SentDM webhook retry count. | +| `webhook_timeout_seconds` | `int` | `30` | SentDM webhook delivery timeout. | +| `persist_records` | `bool` | `true` | Persist a `SentDMBroadcastRecord` per `(recipient, channel)` on each successful send. Webhooks **always** upsert by `sentdm_message_id` (create a minimal record if none exists), independent of this flag. | +| `persist_sandbox_sends` | `bool` | `false` | Persist sandbox sends as well (off by default to keep the graph free of test traffic). | +| `record_event_history_limit` | `int` | `25` | Bound on the `events[]` audit log per record (FIFO eviction). | + +## Graph data model + +When `persist_records` is on, every successful `POST /v3/messages` writes one +`SentDMBroadcastRecord` node per `(message_id, recipient, channel)` and +connects it to the action node, so the graph layout is: + +``` +Agent -> Actions -> SentDMBroadcastAction -> SentDMBroadcastRecord (one per message) +``` + +Indexed fields (cheap lookups): + +| Field | Purpose | +| --- | --- | +| `action_id` | Filter records belonging to a specific action node. | +| `agent_id` | Cross-action queries scoped to one agent. | +| `sentdm_message_id` | Primary key used by the webhook handler to reconcile delivery status into the local record. | + +Mutable fields: + +| Field | Notes | +| --- | --- | +| `status` | Normalized: `accepted` / `processing` / `queued` / `sent` / `delivered` / `read` / `received` / `failed` / `rejected` / `undelivered`. | +| `last_event_field`, `last_event_payload`, `last_status_at` | Snapshot of the most recent webhook (or refresh) event. | +| `events` | Bounded audit log (newest last); cap from `record_event_history_limit`. | +| `error` | Populated when `status in {failed, rejected, undelivered}`. | +| `to`, `channel`, `template_id`, `template_name`, `parameters`, `idempotency_key`, `profile_id`, `sandbox`, `created_at`, `updated_at` | Captured at send time; webhook-only records fill `to` / `channel` / `template_id` from the first event when Sent provides them. | + +Status derivation when a webhook arrives: + +1. If `payload.status` or `payload.message_status` (or nested `data.*`) matches a known token, use it. +2. Else if `payload.event`, `eventType`, or `sub_type` looks like `message.delivered` / `message.sent` / `message.failed`, use the suffix after the dot. +3. Else keep the prior status; only `last_event_field` / `events` are updated. + +If the message id cannot be resolved from the payload, the handler still +returns `200` so SentDM stops retrying; otherwise a record is created or +updated in place. + +## Usage from another action + +```python +sentdm = await self.get_action("SentDMBroadcastAction") +result = await sentdm.send_broadcast( + to=["+14155551234", "+14155555678"], + template={ + "name": "order_confirmation", + "parameters": {"name": "Jane", "order_id": "12345"}, + }, + channels=["sms", "whatsapp"], +) +message_ids = [m.get("id") for m in (result.get("messages") or [])] +``` + +The response is the raw SentDM JSON. Inspect the API reference for the +per-recipient/per-channel message metadata. + +## HTTP endpoints + +All admin endpoints require auth (admin role) and are scoped by the action id: + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/actions/{action_id}/broadcast` | Send a broadcast. Body: `{to, template?, channels?, parameters?, sandbox?, idempotency_key?, profile_id?}` (see OpenAPI / docstring example). | +| `POST` | `/api/actions/{action_id}/webhook/register` | Force webhook reconcile (re-creates SentDM webhook to point at us). | +| `GET` | `/api/actions/{action_id}/webhook` | Read-only view of the currently registered webhook URL + signing-secret status. | + +Public webhook receiver (api_key auth via query/header — registered with SentDM +automatically on startup): + +| Method | Path | +| --- | --- | +| `POST` | `/api/webhook/{action_id}?api_key=...` | + +The handler verifies the `X-Webhook-Signature` HMAC against the stored signing +secret, de-duplicates by `X-Webhook-ID`, looks up the matching +`SentDMBroadcastRecord` (by `sentdm_message_id` parsed out of the event +payload), and folds the event into the record's `status`, `last_status_at`, +`last_event_payload` and `events[]` audit log. + +Webhook **reconcile** (on action load or `POST …/webhook/register`) deletes every +other Sent webhook whose URL starts with +`{JVAGENT_PUBLIC_BASE_URL}/api/webhook/` **or** the legacy +`/api/sentdm/webhook/` prefix on the same host, except the exact URL for **this** +action — including other action ids on the same host. If you run multiple +SentDM broadcast actions behind one public base URL, reconciling one action +removes the others' Sent registrations; use distinct base URLs (or hostnames) +per deployment if you need them concurrently. + +## Interactive test CLI + +A standalone, dependency-light CLI for exercising the endpoints lives at +[test_cli.py](test_cli.py). It does not import any jvagent code — it just +talks to a running jvagent server over HTTP. + +```bash +python jvagent/action/sentdm_broadcast/test_cli.py +python jvagent/action/sentdm_broadcast/test_cli.py --env-file path/to/.env +python jvagent/action/sentdm_broadcast/test_cli.py --no-env # ignore .env files +python jvagent/action/sentdm_broadcast/test_cli.py --prompt # force interactive auth +python jvagent/action/sentdm_broadcast/test_cli.py -y # explicit auto-mode +``` + +By default the CLI runs in **auto-mode** whenever the resolved env supplies +enough to authenticate (`JVAGENT_API_KEY`, or `JVAGENT_ADMIN_EMAIL` + +`JVAGENT_ADMIN_PASSWORD`). In auto-mode it skips every prompt that has a +default, auto-selects the agent that has a `SentDMBroadcastAction`, and drops +straight into the menu. Pass `--prompt` to force the legacy interactive flow. + +What it does: + +1. Loads a `.env` file for prompt defaults. By default it uses + `examples/jvagent_app/.env` in this repository (the same file as the example + app). If that path does not exist, it falls back to walking upward from the + current working directory. Override with `--env-file`. +2. Prompts for the jvagent base URL — the default is + `JVAGENT_BASE_URL` / `JVAGENT_PUBLIC_BASE_URL`, otherwise + `http://{JVAGENT_HOST or localhost}:{JVAGENT_PORT or 8000}`. +3. Authenticates with either admin credentials (`POST /api/auth/login`) or an + existing jvagent API key (`x-api-key` header). +4. Lists agents, lets you pick one, and locates its `SentDMBroadcastAction`. +5. Opens a menu: send broadcast (few prompts; optional ``SENTDM_TEST_*`` in ``.env``), reconcile webhook, + show registered webhook URL, switch agent, quit. + +### Env vars used as defaults + +| Variable | Used for | +| --- | --- | +| `JVAGENT_BASE_URL` *(optional)* | Explicit base URL for the CLI | +| `JVAGENT_PUBLIC_BASE_URL` | Fallback base URL | +| `JVAGENT_HOST`, `JVAGENT_PORT` | Built into `http://host:port` as a last resort | +| `JVAGENT_ADMIN_EMAIL` | Default email for `POST /api/auth/login` (the jvspatial endpoint expects `email`, not `username`); falls back to `JVAGENT_ADMIN_USERNAME` | +| `JVAGENT_ADMIN_PASSWORD` | Skips the password prompt when present | +| `JVAGENT_API_KEY` | Skips the API-key prompt when present | +| `JVAGENT_API_KEY_HEADER` | Default header name (defaults to `x-api-key`) | +| `SENTDM_TEST_TO` | Default recipient list for the send-broadcast prompt (E.164, comma-separated) | +| `SENTDM_TEST_TEMPLATE_ID` | Default template UUID for the send-broadcast prompt | +| `SENTDM_TEST_PARAMETERS_JSON` | Default parameters JSON string for the send-broadcast prompt | + +The CLI requires `httpx` (already a jvagent dependency) and uses +`python-dotenv` (also a jvagent dependency) for `.env` parsing. The last base +URL + chosen auth method are cached at `~/.sentdm_test_cli.json`. **Passwords +and API keys are never written to disk** — they're read fresh from `.env` +(or prompted) on each run. diff --git a/jvagent/action/sentdm_broadcast/__init__.py b/jvagent/action/sentdm_broadcast/__init__.py new file mode 100644 index 00000000..6c083ca1 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/__init__.py @@ -0,0 +1,11 @@ +"""SentDM broadcast action package. + +Provides a thin wrapper around the SentDM v3 REST API for sending template-based +SMS / WhatsApp broadcasts and receiving delivery-status webhooks. +""" + +from . import endpoints # noqa: F401 (import for endpoint registration) +from .models import SentDMBroadcastRecord +from .sentdm_broadcast_action import SentDMBroadcastAction + +__all__ = ["SentDMBroadcastAction", "SentDMBroadcastRecord"] diff --git a/jvagent/action/sentdm_broadcast/docs/llms.txt b/jvagent/action/sentdm_broadcast/docs/llms.txt new file mode 100644 index 00000000..5fac1c9f --- /dev/null +++ b/jvagent/action/sentdm_broadcast/docs/llms.txt @@ -0,0 +1,1455 @@ +API INFORMATION +============================================================ + +Title: SentDM - Public v3 API +Version: 1.0.0 + +Description: +# Sent DM API v3 + +Programmatic SMS and WhatsApp messaging — one API for sending messages, managing templates, contacts, webhooks, and compliance. + +## Authentication + +All requests require the `x-api-key` header. Set your key once in the **Variables** tab of this collection (`apiKey`) and it will be applied to every request automatically. + +## Sandbox Mode + +Every mutation endpoint accepts `"sandbox": true` in the request body. The API validates and returns a realistic response without executing any side effects — no messages sent, no data written. Useful for CI/CD and integration testing. + +## Useful Links + +- 📚 [API Reference](https://docs.sent.dm/reference/api) +- 🚀 [Quickstart — Send your first message](https://docs.sent.dm/start/quickstart/first-message) +- 📝 [Working with Templates](https://docs.sent.dm/start/guides/working-with-templates) +- 📨 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages) +- 🔔 [Webhooks — Getting Started](https://docs.sent.dm/start/webhooks/getting-started) +- 🔔 [Webhook Event Types](https://docs.sent.dm/start/webhooks/event-types) +- ⚙️ [Webhook Local Development](https://docs.sent.dm/start/webhooks/local-development) +- 🔁 [Handling Retries](https://docs.sent.dm/start/webhooks/handling-retries) +- 🧪 [Testing & Debugging](https://docs.sent.dm/start/guides/testing-debugging) +- ❌ [Error Handling](https://docs.sent.dm/start/guides/error-handling) +- 📦 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations) +- 🏢 [Multi-Tenant Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures) + +## Rate Limits + +| Endpoint type | Limit | +| --- | --- | +| Standard | 200 req/min | +| Sensitive (rotate-secret, test-webhook) | 10 req/min | + +On `429`, check the `Retry-After` response header. + +Contact: + +SERVERS +============================================================ + +URL: https://api.sent.dm + +SECURITY +============================================================ + +apiKey: + Type: apiKey + In: header + +ENDPOINTS +============================================================ + +Tag: Webhooks +------------------------------------------------------------ + +GET /v3/webhooks +Operation ID: getWebhooksList +Summary: Get webhooks list + +Description: +Retrieves a paginated list of webhooks for the authenticated customer. + +Parameters: + - page (query) string + Example: "1" + - page_size (query) string + Example: "1" + - search (query) string + Example: "string" + - is_active (query) string + Example: "" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/webhooks +Operation ID: createAWebhook +Summary: Create a webhook + +Description: +Creates a new webhook endpoint for the authenticated customer. + +Parameters: + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - display_name: string + Example: "Order Notifications" + - endpoint_url: string + Example: "https://example.com/webhooks/orders" + - event_types: array + Example: ["messages","templates"] + Items: + - items: string + Example: "messages" + - retry_count: number + Example: 3 + - sandbox: boolean + Example: false + - timeout_seconds: number + Example: 30 + +Responses: + 200: + +GET /v3/webhooks/{id} +Operation ID: getAWebhook +Summary: Get a webhook + +Description: +Retrieves a single webhook by ID for the authenticated customer. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +PUT /v3/webhooks/{id} +Operation ID: updateAWebhook +Summary: Update a webhook + +Description: +Updates an existing webhook for the authenticated customer. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - display_name: string + Example: "Updated Order Notifications" + - endpoint_url: string + Example: "https://example.com/webhooks/orders-v2" + - event_types: array + Example: ["messages","templates"] + Items: + - items: string + Example: "messages" + - retry_count: number + Example: 5 + - sandbox: boolean + Example: false + - timeout_seconds: number + Example: 60 + +Responses: + 200: + +DELETE /v3/webhooks/{id} +Operation ID: deleteAWebhook +Summary: Delete a webhook + +Description: +Deletes a webhook for the authenticated customer. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +GET /v3/webhooks/{id}/events +Operation ID: getWebhookEvents +Summary: Get webhook events + +Description: +Retrieves a paginated list of delivery events for the specified webhook. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - page (query) string + Example: "1" + - page_size (query) string + Example: "1" + - search (query) string + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +GET /v3/webhooks/event-types +Operation ID: getAvailableWebhookEventTypes +Summary: Get available webhook event types + +Description: +Retrieves all available webhook event types that can be subscribed to. + +Parameters: + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/webhooks/{id}/rotate-secret +Operation ID: rotateWebhookSigningSecret +Summary: Rotate webhook signing secret + +Description: +Generates a new signing secret for the specified webhook. The old secret is immediately invalidated. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - sandbox: boolean + Example: false + +Responses: + 200: + +POST /v3/webhooks/{id}/test +Operation ID: testAWebhook +Summary: Test a webhook + +Description: +Sends a test event to the specified webhook endpoint to verify connectivity. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - event_type: string + Example: "message.sent" + - sandbox: boolean + Example: false + +Responses: + 200: + +PATCH /v3/webhooks/{id}/toggle-status +Operation ID: toggleWebhookStatus +Summary: Toggle webhook status + +Description: +Activates or deactivates a webhook for the authenticated customer. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - is_active: boolean + Example: false + - sandbox: boolean + Example: false + +Responses: + 200: + +Tag: Users +------------------------------------------------------------ + +GET /v3/users/{userId} +Operation ID: getUserById +Summary: Get user by ID + +Description: +Retrieves detailed information about a specific user in an organization or profile. Requires developer role or higher. + +Parameters: + - userId (path) string REQUIRED + Example: "{{userId}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +DELETE /v3/users/{userId} +Operation ID: removeUser +Summary: Remove user + +Description: +Removes a user's access to an organization or profile. Requires admin role. You cannot remove yourself or remove the last admin. + +Parameters: + - userId (path) string REQUIRED + Example: "{{userId}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - sandbox: boolean + Example: false + +Responses: + 200: + +PATCH /v3/users/{userId} +Operation ID: updateUserRole +Summary: Update user role + +Description: +Updates a user's role in the organization or profile. Requires admin role. You cannot change your own role or demote the last admin. + +Parameters: + - userId (path) string REQUIRED + Example: "{{userId}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - role: string + Example: "billing" + - sandbox: boolean + Example: false + +Responses: + 200: + +GET /v3/users +Operation ID: listUsers +Summary: List users + +Description: +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. + +Parameters: + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/users +Operation ID: inviteAUser +Summary: Invite a user + +Description: +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. + +Parameters: + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - email: string + Example: "newuser@example.com" + - name: string + Example: "New User" + - role: string + Example: "developer" + - sandbox: boolean + Example: false + +Responses: + 200: + +Tag: Templates +------------------------------------------------------------ + +GET /v3/templates +Operation ID: getTemplatesList +Summary: Get templates list + +Description: +Retrieves a paginated list of message templates for the authenticated customer. Supports filtering by status, category, and search term. + +Parameters: + - page (query) string + Page number (1-indexed) + Example: "1" + - page_size (query) string + Number of items per page + Example: "1" + - search (query) string + Optional search term for filtering templates + Example: "string" + - status (query) string + Optional status filter: APPROVED, PENDING, REJECTED + Example: "string" + - category (query) string + Optional category filter: MARKETING, UTILITY, AUTHENTICATION + Example: "string" + - is_welcome_playground (query) string + Optional filter by welcome playground flag + Example: "" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/templates +Operation ID: createANewTemplate +Summary: Create a new template + +Description: +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. + +Parameters: + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - category: string + Example: "MARKETING" + - definition: object + Properties: + - authenticationConfig: unknown NULLABLE + Example: null + - body: object + Properties: + - multiChannel: object + Properties: + - template: string + Example: "Hello {{0:variable}}! Welcome to {{1:variable}}." + - type: unknown NULLABLE + Example: null + - variables: array + Example: [{"id":0,"name":"name","props":{"alt":null,"mediaType":null,"regex":null,"sample":"John","shortUrl":null,"url":null,"variableType":"text"},"type":"variable"},{"id":1,"name":"company","props":{"alt":null,"mediaType":null,"regex":null,"sample":"SentDM","shortUrl":null,"url":null,"variableType":"text"},"type":"variable"}] + Items: + - items: object + Properties: + - id: number + Example: 0 + - name: string + Example: "name" + - props: object + Properties: + - alt: unknown NULLABLE + Example: null + - mediaType: unknown NULLABLE + Example: null + - regex: unknown NULLABLE + Example: null + - sample: string + Example: "John" + - shortUrl: unknown NULLABLE + Example: null + - url: unknown NULLABLE + Example: null + - variableType: string + Example: "text" + - type: string + Example: "variable" + - sms: unknown NULLABLE + Example: null + - whatsapp: unknown NULLABLE + Example: null + - buttons: unknown NULLABLE + Example: null + - definitionVersion: string + Example: "1.0" + - footer: unknown NULLABLE + Example: null + - header: unknown NULLABLE + Example: null + - language: string + Example: "en_US" + - sandbox: boolean + Example: false + - submit_for_review: boolean + Example: false + +Responses: + 200: + +GET /v3/templates/{id} +Operation ID: getTemplateById +Summary: Get template by ID + +Description: +Retrieves a specific template by its ID. Returns template details including name, category, language, status, and definition. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +PUT /v3/templates/{id} +Operation ID: updateATemplate +Summary: Update a template + +Description: +Updates an existing template's name, category, language, definition, or submits it for review. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - category: string + Example: "MARKETING" + - definition: object + Properties: + - authenticationConfig: object + Properties: + - addSecurityRecommendation: boolean + Example: false + - codeExpirationMinutes: number + Example: 1 + - buttons: array + Example: [{"id":1,"type":"string"}] + Items: + - items: object + Properties: + - id: number + Example: 1 + - type: string + Example: "string" + - definitionVersion: string + Example: "string" + - footer: object + Properties: + - template: string + Example: "string" + - type: string + Example: "string" + - variables: array + Example: [{"id":1,"name":"string","props":{},"type":"string"}] + Items: + - items: object + Properties: + - id: number + Example: 1 + - name: string + Example: "string" + - props: object + - type: string + Example: "string" + - header: object + Properties: + - template: string + Example: "string" + - type: string + Example: "string" + - variables: array + Example: [{"id":1,"name":"string","props":{},"type":"string"}] + Items: + - items: object + Properties: + - id: number + Example: 1 + - name: string + Example: "string" + - props: object + - type: string + Example: "string" + - name: string + Example: "Updated Welcome Message" + - sandbox: boolean + Example: false + - submit_for_review: boolean + Example: false + +Responses: + 200: + +DELETE /v3/templates/{id} +Operation ID: deleteATemplate +Summary: Delete a template + +Description: +Deletes a template by ID. Optionally, you can also delete the template from WhatsApp/Meta by setting delete_from_meta=true. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - delete_from_meta: boolean + Example: false + - sandbox: boolean + Example: false + +Responses: + 200: + +Tag: Profiles +------------------------------------------------------------ + +POST /v3/profiles/{profileId}/complete +Operation ID: completeProfileSetup +Summary: Complete profile setup + +Description: +Final step in profile compliance workflow. Validates all prerequisites (general data, brand, campaigns), connects profile to Telnyx/WhatsApp, and sets status based on configuration. The process runs in the background and calls the provided webhook URL when finished. + + Prerequisites: + - Profile must be completed + - If inheritTcrBrand=false: Profile must have existing brand + - If inheritTcrBrand=true: Parent must have existing brand + - If TCR application: Must have at least one campaign (own or inherited) + - If inheritTcrCampaign=false: Profile should have campaigns + - If inheritTcrCampaign=true: Parent must have campaigns + + Status Logic: + - If both SMS and WhatsApp channels are missing → SUBMITTED + - If TCR application and not inheriting brand/campaigns → SUBMITTED + - If non-TCR with destination country (IsMain=true) → SUBMITTED + - Otherwise → COMPLETED + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - sandbox: boolean + Example: false + - webHookUrl: string + Example: "https://your-app.com/webhook/profile-complete" + +Responses: + 200: + +GET /v3/profiles +Operation ID: listProfilesInOrganization +Summary: List profiles in organization + +Description: +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. + +Parameters: + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/profiles +Operation ID: createANewProfile +Summary: Create a new profile + +Description: +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. + +Parameters: + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - allow_contact_sharing: boolean + Example: true + - allow_template_sharing: boolean + Example: false + - billing_contact: object + Properties: + - address: string + Example: "string" + - email: string + Example: "string" + - name: string + Example: "string" + - phone: string + Example: "string" + - billing_model: string + Example: "profile" + - brand: object + Properties: + - business: object + Properties: + - city: string + Example: "string" + - country: string + Example: "string" + - countryOfRegistration: string + Example: "string" + - legalName: string + Example: "string" + - postalCode: string + Example: "string" + - state: string + Example: "string" + - street: string + Example: "string" + - taxId: string + Example: "string" + - taxIdType: string + Example: "string" + - url: string + Example: "https://example.com/webhook" + - description: string + Example: "Sales department sender profile" + - icon: string + Example: "https://example.com/sales-icon.png" + - inherit_contacts: boolean + Example: true + - inherit_tcr_brand: boolean + Example: false + - inherit_tcr_campaign: boolean + Example: false + - inherit_templates: boolean + Example: true + - name: string + Example: "Sales Team" + - payment_details: object + Properties: + - card_number: string + Example: "string" + - cvc: string + Example: "string" + - expiry: string + Example: "string" + - zip_code: string + Example: "string" + - sandbox: boolean + Example: false + - short_name: string + Example: "SALES" + - whatsapp_business_account: object + Properties: + - access_token: string + Example: "string" + - phone_number_id: string + Example: "string" + - waba_id: string + Example: "string" + +Responses: + 200: + +GET /v3/profiles/{profileId} +Operation ID: getProfileById +Summary: Get profile by ID + +Description: +Retrieves detailed information about a specific sender profile within an organization, including brand and KYC information if a brand has been configured. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +DELETE /v3/profiles/{profileId} +Operation ID: deleteAProfile +Summary: Delete a profile + +Description: +Soft deletes a sender profile. The profile will be marked as deleted but data is retained. Requires admin role in the organization. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - sandbox: boolean + Example: false + +Responses: + 200: + +PATCH /v3/profiles/{profileId} +Operation ID: updateProfileSettings +Summary: Update profile settings + +Description: +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. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - allow_contact_sharing: boolean + Example: true + - billing_contact: object + Properties: + - address: string + Example: "string" + - email: string + Example: "string" + - name: string + Example: "string" + - phone: string + Example: "string" + - billing_model: string + Example: "organization" + - brand: object + Properties: + - business: object + Properties: + - city: string + Example: "string" + - country: string + Example: "string" + - countryOfRegistration: string + Example: "string" + - legalName: string + Example: "string" + - postalCode: string + Example: "string" + - state: string + Example: "string" + - street: string + Example: "string" + - taxId: string + Example: "string" + - taxIdType: string + Example: "string" + - url: string + Example: "https://example.com/webhook" + - description: string + Example: "Updated sales department sender profile" + - name: string + Example: "Sales Team - Updated" + - payment_details: object + Properties: + - card_number: string + Example: "string" + - cvc: string + Example: "string" + - expiry: string + Example: "string" + - zip_code: string + Example: "string" + - sandbox: boolean + Example: false + - short_name: string + Example: "SALES" + +Responses: + 200: + +GET /v3/profiles/{profileId}/campaigns +Operation ID: getCampaignsForAProfileSBrand +Summary: Get campaigns for a profile's brand + +Description: +Retrieves all campaigns linked to the profile's brand, including use cases and sample messages. Returns inherited campaigns if inherit_tcr_campaign=true. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/profiles/{profileId}/campaigns +Operation ID: createACampaignForAProfileSBrand +Summary: Create a campaign for a profile's brand + +Description: +Creates a new campaign scoped under the brand of the specified profile. Each campaign must include at least one use case with sample messages. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - campaign: object + Properties: + - description: string + Example: "Appointment reminders and account notifications" + - helpKeywords: string + Example: "HELP, INFO, SUPPORT" + - helpMessage: string + Example: "Reply STOP to unsubscribe or contact support@acmecorp.com" + - messageFlow: string + Example: "User signs up on website and opts in to receive SMS notifications" + - name: string + Example: "Customer Notifications" + - optinKeywords: string + Example: "YES, START, SUBSCRIBE" + - optinMessage: string + Example: "You have opted in to Acme Corp notifications. Reply STOP to opt out." + - optoutKeywords: string + Example: "STOP, UNSUBSCRIBE, END" + - optoutMessage: string + Example: "You have been unsubscribed. Reply START to opt back in." + - privacyPolicyLink: string + Example: "https://acmecorp.com/privacy" + - termsAndConditionsLink: string + Example: "https://acmecorp.com/terms" + - type: string + Example: "App" + - useCases: array + Example: [{"messagingUseCaseUs":"ACCOUNT_NOTIFICATION","sampleMessages":["Hi {name}, your appointment is confirmed for {date} at {time}.","Your order #{order_id} has been shipped. Track at {url}"]}] + Items: + - items: object + Properties: + - messagingUseCaseUs: string + Example: "ACCOUNT_NOTIFICATION" + - sampleMessages: array + Example: ["Hi {name}, your appointment is confirmed for {date} at {time}.","Your order #{order_id} has been shipped. Track at {url}"] + Items: + - items: string + Example: "Hi {name}, your appointment is confirmed for {date} at {time}." + - sandbox: boolean + Example: false + +Responses: + 200: + +PUT /v3/profiles/{profileId}/campaigns/{campaignId} +Operation ID: updateACampaign +Summary: Update a campaign + +Description: +Updates an existing campaign under the brand of the specified profile. Cannot update campaigns that have already been submitted to TCR. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - campaignId (path) string REQUIRED + Example: "{{campaignId}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - campaign: object + Properties: + - description: string + Example: "Updated appointment reminders and account notifications" + - helpKeywords: unknown NULLABLE + Example: null + - helpMessage: unknown NULLABLE + Example: null + - messageFlow: string + Example: "User signs up on website and opts in to receive SMS notifications" + - name: string + Example: "Customer Notifications Updated" + - optinKeywords: unknown NULLABLE + Example: null + - optinMessage: unknown NULLABLE + Example: null + - optoutKeywords: unknown NULLABLE + Example: null + - optoutMessage: unknown NULLABLE + Example: null + - privacyPolicyLink: unknown NULLABLE + Example: null + - termsAndConditionsLink: unknown NULLABLE + Example: null + - type: string + Example: "App" + - useCases: array + Example: [{"messagingUseCaseUs":"ACCOUNT_NOTIFICATION","sampleMessages":["Hi {name}, your appointment is confirmed for {date} at {time}.","Your order #{order_id} has been shipped. Track at {url}"]}] + Items: + - items: object + Properties: + - messagingUseCaseUs: string + Example: "ACCOUNT_NOTIFICATION" + - sampleMessages: array + Example: ["Hi {name}, your appointment is confirmed for {date} at {time}.","Your order #{order_id} has been shipped. Track at {url}"] + Items: + - items: string + Example: "Hi {name}, your appointment is confirmed for {date} at {time}." + - sandbox: boolean + Example: false + +Responses: + 200: + +DELETE /v3/profiles/{profileId}/campaigns/{campaignId} +Operation ID: deleteACampaign +Summary: Delete a campaign + +Description: +Deletes a campaign by ID from the brand of the specified profile. The profile must belong to the authenticated organization. + +Parameters: + - profileId (path) string REQUIRED + Example: "{{profileId}}" + - campaignId (path) string REQUIRED + Example: "{{campaignId}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - sandbox: boolean + Example: false + +Responses: + 200: + +Tag: Numbers +------------------------------------------------------------ + +GET /v3/numbers/lookup/{phoneNumber} +Operation ID: getPhoneNumberDetails +Summary: Get phone number details + +Description: +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. + +Parameters: + - phoneNumber (path) string REQUIRED + Example: "{{phoneNumber}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +Tag: Messages +------------------------------------------------------------ + +GET /v3/messages/{id}/activities +Operation ID: getMessageActivities +Summary: Get message activities + +Description: +Retrieves the activity log for a specific message. Activities track the message lifecycle including acceptance, processing, sending, delivery, and any errors. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +GET /v3/messages/{id} +Operation ID: getMessageStatus +Summary: Get message status + +Description: +Retrieves the current status and details of a message by ID. Includes delivery status, timestamps, and error information if applicable. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/messages +Operation ID: sendAMessage +Summary: Send a message + +Description: +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. + +Parameters: + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - channel: array + Example: ["sms","whatsapp"] + Items: + - items: string + Example: "sms" + - sandbox: boolean + Example: false + - template: object + Properties: + - id: string + Example: "7ba7b820-9dad-11d1-80b4-00c04fd430c8" + - name: string + Example: "order_confirmation" + - parameters: object + Properties: + - name: string + Example: "John Doe" + - order_id: string + Example: "12345" + - to: array + Example: ["+14155551234","+14155555678"] + Items: + - items: string + Example: "+14155551234" + +Responses: + 200: + +Tag: Contacts +------------------------------------------------------------ + +GET /v3/contacts +Operation ID: getContactsList +Summary: Get contacts list + +Description: +Retrieves a paginated list of contacts for the authenticated customer. Supports filtering by search term, channel, or phone number. + +Parameters: + - page (query) string + Page number (1-indexed) + Example: "1" + - page_size (query) string + Number of items per page + Example: "1" + - search (query) string + Optional search term for filtering contacts + Example: "string" + - channel (query) string + Optional channel filter (sms, whatsapp) + Example: "string" + - phone (query) string + Optional phone number filter (alternative to list view) + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +POST /v3/contacts +Operation ID: createAContact +Summary: Create a contact + +Description: +Creates a new contact by phone number and associates it with the authenticated customer. + +Parameters: + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - phone_number: string + Example: "+1234567890" + - sandbox: boolean + Example: false + +Responses: + 200: + +GET /v3/contacts/{id} +Operation ID: getContactById +Summary: Get contact by ID + +Description: +Retrieves a specific contact by their unique identifier. Returns detailed contact information including phone formats, available channels, and opt-out status. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: + +DELETE /v3/contacts/{id} +Operation ID: deleteAContact +Summary: Delete a contact + +Description: +Dissociates a contact from the authenticated customer. Inherited contacts cannot be deleted. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - sandbox: boolean + Example: false + +Responses: + 200: + +PATCH /v3/contacts/{id} +Operation ID: updateAContact +Summary: Update a contact + +Description: +Updates a contact's default channel and/or opt-out status. Inherited contacts cannot be updated. + +Parameters: + - id (path) string REQUIRED + Example: "{{id}}" + - idempotency-key (header) string + 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. + Example: "string" + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Request Body (application/json): + + Schema: + - body: object REQUIRED + Properties: + - default_channel: string + Example: "whatsapp" + - opt_out: boolean + Example: false + - sandbox: boolean + Example: false + +Responses: + 200: + +Tag: Accounts +------------------------------------------------------------ + +GET /v3/me +Operation ID: getAuthenticatedAccount +Summary: Get authenticated account + +Description: +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`. + +Parameters: + - x-profile-id (header) string + 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. + Example: "{{$guid}}" + +Responses: + 200: diff --git a/jvagent/action/sentdm_broadcast/docs/openapi.json b/jvagent/action/sentdm_broadcast/docs/openapi.json new file mode 100644 index 00000000..bde0b482 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/docs/openapi.json @@ -0,0 +1,3159 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "SentDM - Public v3 API", + "description": "# Sent DM API v3\n\nProgrammatic SMS and WhatsApp messaging — one API for sending messages, managing templates, contacts, webhooks, and compliance.\n\n## Authentication\n\nAll requests require the `x-api-key` header. Set your key once in the **Variables** tab of this collection (`apiKey`) and it will be applied to every request automatically.\n\n## Sandbox Mode\n\nEvery mutation endpoint accepts `\"sandbox\": true` in the request body. The API validates and returns a realistic response without executing any side effects — no messages sent, no data written. Useful for CI/CD and integration testing.\n\n## Useful Links\n\n- 📚 [API Reference](https://docs.sent.dm/reference/api)\n- 🚀 [Quickstart — Send your first message](https://docs.sent.dm/start/quickstart/first-message)\n- 📝 [Working with Templates](https://docs.sent.dm/start/guides/working-with-templates)\n- 📨 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages)\n- 🔔 [Webhooks — Getting Started](https://docs.sent.dm/start/webhooks/getting-started)\n- 🔔 [Webhook Event Types](https://docs.sent.dm/start/webhooks/event-types)\n- ⚙️ [Webhook Local Development](https://docs.sent.dm/start/webhooks/local-development)\n- 🔁 [Handling Retries](https://docs.sent.dm/start/webhooks/handling-retries)\n- 🧪 [Testing & Debugging](https://docs.sent.dm/start/guides/testing-debugging)\n- ❌ [Error Handling](https://docs.sent.dm/start/guides/error-handling)\n- 📦 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations)\n- 🏢 [Multi-Tenant Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures)\n\n## Rate Limits\n\n| Endpoint type | Limit |\n| --- | --- |\n| Standard | 200 req/min |\n| Sensitive (rotate-secret, test-webhook) | 10 req/min |\n\nOn `429`, check the `Retry-After` response header.", + "version": "1.0.0", + "contact": {} + }, + "servers": [ + { + "url": "https://api.sent.dm" + } + ], + "paths": { + "/v3/webhooks": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "Get webhooks list", + "description": "Retrieves a paginated list of webhooks for the authenticated customer.", + "operationId": "getWebhooksList", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "string", + "example": "1" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "string", + "example": "1" + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "example": "string" + } + }, + { + "name": "is_active", + "in": "query", + "schema": { + "type": "string", + "example": "" + } + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Create a webhook", + "description": "Creates a new webhook endpoint for the authenticated customer.", + "operationId": "createAWebhook", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "example": "Order Notifications" + }, + "endpoint_url": { + "type": "string", + "example": "https://example.com/webhooks/orders" + }, + "event_types": { + "type": "array", + "items": { + "type": "string", + "example": "messages" + }, + "example": [ + "messages", + "templates" + ] + }, + "retry_count": { + "type": "number", + "example": 3 + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "timeout_seconds": { + "type": "number", + "example": 30 + } + } + }, + "examples": { + "Create a webhook": { + "value": { + "display_name": "Order Notifications", + "endpoint_url": "https://example.com/webhooks/orders", + "event_types": [ + "messages", + "templates" + ], + "retry_count": 3, + "sandbox": false, + "timeout_seconds": 30 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v3/webhooks/{id}": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "Get a webhook", + "description": "Retrieves a single webhook by ID for the authenticated customer.", + "operationId": "getAWebhook", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "put": { + "tags": [ + "Webhooks" + ], + "summary": "Update a webhook", + "description": "Updates an existing webhook for the authenticated customer.", + "operationId": "updateAWebhook", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "example": "Updated Order Notifications" + }, + "endpoint_url": { + "type": "string", + "example": "https://example.com/webhooks/orders-v2" + }, + "event_types": { + "type": "array", + "items": { + "type": "string", + "example": "messages" + }, + "example": [ + "messages", + "templates" + ] + }, + "retry_count": { + "type": "number", + "example": 5 + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "timeout_seconds": { + "type": "number", + "example": 60 + } + } + }, + "examples": { + "Update a webhook": { + "value": { + "display_name": "Updated Order Notifications", + "endpoint_url": "https://example.com/webhooks/orders-v2", + "event_types": [ + "messages", + "templates" + ], + "retry_count": 5, + "sandbox": false, + "timeout_seconds": 60 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Webhooks" + ], + "summary": "Delete a webhook", + "description": "Deletes a webhook for the authenticated customer.", + "operationId": "deleteAWebhook", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/webhooks/{id}/events": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "Get webhook events", + "description": "Retrieves a paginated list of delivery events for the specified webhook.", + "operationId": "getWebhookEvents", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "string", + "example": "1" + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "string", + "example": "1" + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "example": "string" + } + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/webhooks/event-types": { + "get": { + "tags": [ + "Webhooks" + ], + "summary": "Get available webhook event types", + "description": "Retrieves all available webhook event types that can be subscribed to.", + "operationId": "getAvailableWebhookEventTypes", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + } + }, + "/v3/webhooks/{id}/rotate-secret": { + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Rotate webhook signing secret", + "description": "Generates a new signing secret for the specified webhook. The old secret is immediately invalidated.", + "operationId": "rotateWebhookSigningSecret", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Rotate webhook signing secret": { + "value": { + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/webhooks/{id}/test": { + "post": { + "tags": [ + "Webhooks" + ], + "summary": "Test a webhook", + "description": "Sends a test event to the specified webhook endpoint to verify connectivity.", + "operationId": "testAWebhook", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "event_type": { + "type": "string", + "example": "message.sent" + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Test a webhook": { + "value": { + "event_type": "message.sent", + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/webhooks/{id}/toggle-status": { + "patch": { + "tags": [ + "Webhooks" + ], + "summary": "Toggle webhook status", + "description": "Activates or deactivates a webhook for the authenticated customer.", + "operationId": "toggleWebhookStatus", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean", + "example": false + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Toggle webhook status": { + "value": { + "is_active": false, + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/users/{userId}": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get user by ID", + "description": "Retrieves detailed information about a specific user in an organization or profile. Requires developer role or higher.", + "operationId": "getUserById", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Remove user", + "description": "Removes a user's access to an organization or profile. Requires admin role. You cannot remove yourself or remove the last admin.", + "operationId": "removeUser", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Remove user": { + "value": { + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "patch": { + "tags": [ + "Users" + ], + "summary": "Update user role", + "description": "Updates a user's role in the organization or profile. Requires admin role. You cannot change your own role or demote the last admin.", + "operationId": "updateUserRole", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "example": "billing" + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Update user role": { + "value": { + "role": "billing", + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{userId}}" + } + } + ] + }, + "/v3/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List users", + "description": "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.", + "operationId": "listUsers", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Invite a user", + "description": "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.", + "operationId": "inviteAUser", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "newuser@example.com" + }, + "name": { + "type": "string", + "example": "New User" + }, + "role": { + "type": "string", + "example": "developer" + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Invite a user": { + "value": { + "email": "newuser@example.com", + "name": "New User", + "role": "developer", + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v3/templates": { + "get": { + "tags": [ + "Templates" + ], + "summary": "Get templates list", + "description": "Retrieves a paginated list of message templates for the authenticated customer. Supports filtering by status, category, and search term.", + "operationId": "getTemplatesList", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "string", + "example": "1" + }, + "description": "Page number (1-indexed)" + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "string", + "example": "1" + }, + "description": "Number of items per page" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "example": "string" + }, + "description": "Optional search term for filtering templates" + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "example": "string" + }, + "description": "Optional status filter: APPROVED, PENDING, REJECTED" + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string", + "example": "string" + }, + "description": "Optional category filter: MARKETING, UTILITY, AUTHENTICATION" + }, + { + "name": "is_welcome_playground", + "in": "query", + "schema": { + "type": "string", + "example": "" + }, + "description": "Optional filter by welcome playground flag" + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Templates" + ], + "summary": "Create a new template", + "description": "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.", + "operationId": "createANewTemplate", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "example": "MARKETING" + }, + "definition": { + "type": "object", + "properties": { + "authenticationConfig": { + "nullable": true, + "example": null + }, + "body": { + "type": "object", + "properties": { + "multiChannel": { + "type": "object", + "properties": { + "template": { + "type": "string", + "example": "Hello {{0:variable}}! Welcome to {{1:variable}}." + }, + "type": { + "nullable": true, + "example": null + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 0 + }, + "name": { + "type": "string", + "example": "name" + }, + "props": { + "type": "object", + "properties": { + "alt": { + "nullable": true, + "example": null + }, + "mediaType": { + "nullable": true, + "example": null + }, + "regex": { + "nullable": true, + "example": null + }, + "sample": { + "type": "string", + "example": "John" + }, + "shortUrl": { + "nullable": true, + "example": null + }, + "url": { + "nullable": true, + "example": null + }, + "variableType": { + "type": "string", + "example": "text" + } + } + }, + "type": { + "type": "string", + "example": "variable" + } + } + }, + "example": [ + { + "id": 0, + "name": "name", + "props": { + "alt": null, + "mediaType": null, + "regex": null, + "sample": "John", + "shortUrl": null, + "url": null, + "variableType": "text" + }, + "type": "variable" + }, + { + "id": 1, + "name": "company", + "props": { + "alt": null, + "mediaType": null, + "regex": null, + "sample": "SentDM", + "shortUrl": null, + "url": null, + "variableType": "text" + }, + "type": "variable" + } + ] + } + } + }, + "sms": { + "nullable": true, + "example": null + }, + "whatsapp": { + "nullable": true, + "example": null + } + } + }, + "buttons": { + "nullable": true, + "example": null + }, + "definitionVersion": { + "type": "string", + "example": "1.0" + }, + "footer": { + "nullable": true, + "example": null + }, + "header": { + "nullable": true, + "example": null + } + } + }, + "language": { + "type": "string", + "example": "en_US" + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "submit_for_review": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Create a new template": { + "value": { + "category": "MARKETING", + "definition": { + "authenticationConfig": null, + "body": { + "multiChannel": { + "template": "Hello {{0:variable}}! Welcome to {{1:variable}}.", + "type": null, + "variables": [ + { + "id": 0, + "name": "name", + "props": { + "alt": null, + "mediaType": null, + "regex": null, + "sample": "John", + "shortUrl": null, + "url": null, + "variableType": "text" + }, + "type": "variable" + }, + { + "id": 1, + "name": "company", + "props": { + "alt": null, + "mediaType": null, + "regex": null, + "sample": "SentDM", + "shortUrl": null, + "url": null, + "variableType": "text" + }, + "type": "variable" + } + ] + }, + "sms": null, + "whatsapp": null + }, + "buttons": null, + "definitionVersion": "1.0", + "footer": null, + "header": null + }, + "language": "en_US", + "sandbox": false, + "submit_for_review": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v3/templates/{id}": { + "get": { + "tags": [ + "Templates" + ], + "summary": "Get template by ID", + "description": "Retrieves a specific template by its ID. Returns template details including name, category, language, status, and definition.", + "operationId": "getTemplateById", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "put": { + "tags": [ + "Templates" + ], + "summary": "Update a template", + "description": "Updates an existing template's name, category, language, definition, or submits it for review.", + "operationId": "updateATemplate", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "example": "MARKETING" + }, + "definition": { + "type": "object", + "properties": { + "authenticationConfig": { + "type": "object", + "properties": { + "addSecurityRecommendation": { + "type": "boolean", + "example": false + }, + "codeExpirationMinutes": { + "type": "number", + "example": 1 + } + } + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": [ + { + "id": 1, + "type": "string" + } + ] + }, + "definitionVersion": { + "type": "string", + "example": "string" + }, + "footer": { + "type": "object", + "properties": { + "template": { + "type": "string", + "example": "string" + }, + "type": { + "type": "string", + "example": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "name": { + "type": "string", + "example": "string" + }, + "props": { + "type": "object", + "properties": {} + }, + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": [ + { + "id": 1, + "name": "string", + "props": {}, + "type": "string" + } + ] + } + } + }, + "header": { + "type": "object", + "properties": { + "template": { + "type": "string", + "example": "string" + }, + "type": { + "type": "string", + "example": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "name": { + "type": "string", + "example": "string" + }, + "props": { + "type": "object", + "properties": {} + }, + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": [ + { + "id": 1, + "name": "string", + "props": {}, + "type": "string" + } + ] + } + } + } + } + }, + "name": { + "type": "string", + "example": "Updated Welcome Message" + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "submit_for_review": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Update a template": { + "value": { + "category": "MARKETING", + "definition": { + "authenticationConfig": { + "addSecurityRecommendation": false, + "codeExpirationMinutes": 1 + }, + "buttons": [ + { + "id": 1, + "type": "string" + } + ], + "definitionVersion": "string", + "footer": { + "template": "string", + "type": "string", + "variables": [ + { + "id": 1, + "name": "string", + "props": {}, + "type": "string" + } + ] + }, + "header": { + "template": "string", + "type": "string", + "variables": [ + { + "id": 1, + "name": "string", + "props": {}, + "type": "string" + } + ] + } + }, + "name": "Updated Welcome Message", + "sandbox": false, + "submit_for_review": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Templates" + ], + "summary": "Delete a template", + "description": "Deletes a template by ID. Optionally, you can also delete the template from WhatsApp/Meta by setting delete_from_meta=true.", + "operationId": "deleteATemplate", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "delete_from_meta": { + "type": "boolean", + "example": false + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Delete a template": { + "value": { + "delete_from_meta": false, + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/profiles/{profileId}/complete": { + "post": { + "tags": [ + "Profiles" + ], + "summary": "Complete profile setup", + "description": "Final step in profile compliance workflow. Validates all prerequisites (general data, brand, campaigns), connects profile to Telnyx/WhatsApp, and sets status based on configuration. The process runs in the background and calls the provided webhook URL when finished.\n\n Prerequisites:\n - Profile must be completed\n - If inheritTcrBrand=false: Profile must have existing brand\n - If inheritTcrBrand=true: Parent must have existing brand\n - If TCR application: Must have at least one campaign (own or inherited)\n - If inheritTcrCampaign=false: Profile should have campaigns\n - If inheritTcrCampaign=true: Parent must have campaigns\n\n Status Logic:\n - If both SMS and WhatsApp channels are missing → SUBMITTED\n - If TCR application and not inheriting brand/campaigns → SUBMITTED\n - If non-TCR with destination country (IsMain=true) → SUBMITTED\n - Otherwise → COMPLETED", + "operationId": "completeProfileSetup", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "example": false + }, + "webHookUrl": { + "type": "string", + "example": "https://your-app.com/webhook/profile-complete" + } + } + }, + "examples": { + "Complete profile setup": { + "value": { + "sandbox": false, + "webHookUrl": "https://your-app.com/webhook/profile-complete" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "profileId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{profileId}}" + } + } + ] + }, + "/v3/profiles": { + "get": { + "tags": [ + "Profiles" + ], + "summary": "List profiles in organization", + "description": "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.", + "operationId": "listProfilesInOrganization", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Profiles" + ], + "summary": "Create a new profile", + "description": "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.\n\n## WhatsApp Business Account\n\nEvery profile must be linked to a WhatsApp Business Account. There are two ways to do this:\n\n**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.\n\n**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.\n\nIf the `whatsapp_business_account` field is omitted and the organization has no WhatsApp Business Account configured, the request will be rejected with HTTP 422.\n\n## Brand\n\nInclude the optional `brand` field to create the brand for this profile at the same time. Cannot be used when `inherit_tcr_brand` is `true`.\n\n## Payment Details\n\nWhen `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.", + "operationId": "createANewProfile", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allow_contact_sharing": { + "type": "boolean", + "example": true + }, + "allow_template_sharing": { + "type": "boolean", + "example": false + }, + "billing_contact": { + "type": "object", + "properties": { + "address": { + "type": "string", + "example": "string" + }, + "email": { + "type": "string", + "example": "string" + }, + "name": { + "type": "string", + "example": "string" + }, + "phone": { + "type": "string", + "example": "string" + } + } + }, + "billing_model": { + "type": "string", + "example": "profile" + }, + "brand": { + "type": "object", + "properties": { + "business": { + "type": "object", + "properties": { + "city": { + "type": "string", + "example": "string" + }, + "country": { + "type": "string", + "example": "string" + }, + "countryOfRegistration": { + "type": "string", + "example": "string" + }, + "legalName": { + "type": "string", + "example": "string" + }, + "postalCode": { + "type": "string", + "example": "string" + }, + "state": { + "type": "string", + "example": "string" + }, + "street": { + "type": "string", + "example": "string" + }, + "taxId": { + "type": "string", + "example": "string" + }, + "taxIdType": { + "type": "string", + "example": "string" + }, + "url": { + "type": "string", + "example": "https://example.com/webhook" + } + } + } + } + }, + "description": { + "type": "string", + "example": "Sales department sender profile" + }, + "icon": { + "type": "string", + "example": "https://example.com/sales-icon.png" + }, + "inherit_contacts": { + "type": "boolean", + "example": true + }, + "inherit_tcr_brand": { + "type": "boolean", + "example": false + }, + "inherit_tcr_campaign": { + "type": "boolean", + "example": false + }, + "inherit_templates": { + "type": "boolean", + "example": true + }, + "name": { + "type": "string", + "example": "Sales Team" + }, + "payment_details": { + "type": "object", + "properties": { + "card_number": { + "type": "string", + "example": "string" + }, + "cvc": { + "type": "string", + "example": "string" + }, + "expiry": { + "type": "string", + "example": "string" + }, + "zip_code": { + "type": "string", + "example": "string" + } + } + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "short_name": { + "type": "string", + "example": "SALES" + }, + "whatsapp_business_account": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "string" + }, + "phone_number_id": { + "type": "string", + "example": "string" + }, + "waba_id": { + "type": "string", + "example": "string" + } + } + } + } + }, + "examples": { + "Create a new profile": { + "value": { + "allow_contact_sharing": true, + "allow_template_sharing": false, + "billing_contact": { + "address": "string", + "email": "string", + "name": "string", + "phone": "string" + }, + "billing_model": "profile", + "brand": { + "business": { + "city": "string", + "country": "string", + "countryOfRegistration": "string", + "legalName": "string", + "postalCode": "string", + "state": "string", + "street": "string", + "taxId": "string", + "taxIdType": "string", + "url": "https://example.com/webhook" + } + }, + "description": "Sales department sender profile", + "icon": "https://example.com/sales-icon.png", + "inherit_contacts": true, + "inherit_tcr_brand": false, + "inherit_tcr_campaign": false, + "inherit_templates": true, + "name": "Sales Team", + "payment_details": { + "card_number": "string", + "cvc": "string", + "expiry": "string", + "zip_code": "string" + }, + "sandbox": false, + "short_name": "SALES", + "whatsapp_business_account": { + "access_token": "string", + "phone_number_id": "string", + "waba_id": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v3/profiles/{profileId}": { + "get": { + "tags": [ + "Profiles" + ], + "summary": "Get profile by ID", + "description": "Retrieves detailed information about a specific sender profile within an organization, including brand and KYC information if a brand has been configured.", + "operationId": "getProfileById", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Profiles" + ], + "summary": "Delete a profile", + "description": "Soft deletes a sender profile. The profile will be marked as deleted but data is retained. Requires admin role in the organization.", + "operationId": "deleteAProfile", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Delete a profile": { + "value": { + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "patch": { + "tags": [ + "Profiles" + ], + "summary": "Update profile settings", + "description": "Updates a profile's configuration and settings. Requires admin role in the organization. Only provided fields will be updated (partial update).\n\n## Brand Management\n\nInclude 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.\n\n## Payment Details\n\nWhen `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.", + "operationId": "updateProfileSettings", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allow_contact_sharing": { + "type": "boolean", + "example": true + }, + "billing_contact": { + "type": "object", + "properties": { + "address": { + "type": "string", + "example": "string" + }, + "email": { + "type": "string", + "example": "string" + }, + "name": { + "type": "string", + "example": "string" + }, + "phone": { + "type": "string", + "example": "string" + } + } + }, + "billing_model": { + "type": "string", + "example": "organization" + }, + "brand": { + "type": "object", + "properties": { + "business": { + "type": "object", + "properties": { + "city": { + "type": "string", + "example": "string" + }, + "country": { + "type": "string", + "example": "string" + }, + "countryOfRegistration": { + "type": "string", + "example": "string" + }, + "legalName": { + "type": "string", + "example": "string" + }, + "postalCode": { + "type": "string", + "example": "string" + }, + "state": { + "type": "string", + "example": "string" + }, + "street": { + "type": "string", + "example": "string" + }, + "taxId": { + "type": "string", + "example": "string" + }, + "taxIdType": { + "type": "string", + "example": "string" + }, + "url": { + "type": "string", + "example": "https://example.com/webhook" + } + } + } + } + }, + "description": { + "type": "string", + "example": "Updated sales department sender profile" + }, + "name": { + "type": "string", + "example": "Sales Team - Updated" + }, + "payment_details": { + "type": "object", + "properties": { + "card_number": { + "type": "string", + "example": "string" + }, + "cvc": { + "type": "string", + "example": "string" + }, + "expiry": { + "type": "string", + "example": "string" + }, + "zip_code": { + "type": "string", + "example": "string" + } + } + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "short_name": { + "type": "string", + "example": "SALES" + } + } + }, + "examples": { + "Update profile settings": { + "value": { + "allow_contact_sharing": true, + "billing_contact": { + "address": "string", + "email": "string", + "name": "string", + "phone": "string" + }, + "billing_model": "organization", + "brand": { + "business": { + "city": "string", + "country": "string", + "countryOfRegistration": "string", + "legalName": "string", + "postalCode": "string", + "state": "string", + "street": "string", + "taxId": "string", + "taxIdType": "string", + "url": "https://example.com/webhook" + } + }, + "description": "Updated sales department sender profile", + "name": "Sales Team - Updated", + "payment_details": { + "card_number": "string", + "cvc": "string", + "expiry": "string", + "zip_code": "string" + }, + "sandbox": false, + "short_name": "SALES" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "profileId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{profileId}}" + } + } + ] + }, + "/v3/profiles/{profileId}/campaigns": { + "get": { + "tags": [ + "Profiles" + ], + "summary": "Get campaigns for a profile's brand", + "description": "Retrieves all campaigns linked to the profile's brand, including use cases and sample messages. Returns inherited campaigns if inherit_tcr_campaign=true.", + "operationId": "getCampaignsForAProfileSBrand", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Profiles" + ], + "summary": "Create a campaign for a profile's brand", + "description": "Creates a new campaign scoped under the brand of the specified profile. Each campaign must include at least one use case with sample messages.", + "operationId": "createACampaignForAProfileSBrand", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "campaign": { + "type": "object", + "properties": { + "description": { + "type": "string", + "example": "Appointment reminders and account notifications" + }, + "helpKeywords": { + "type": "string", + "example": "HELP, INFO, SUPPORT" + }, + "helpMessage": { + "type": "string", + "example": "Reply STOP to unsubscribe or contact support@acmecorp.com" + }, + "messageFlow": { + "type": "string", + "example": "User signs up on website and opts in to receive SMS notifications" + }, + "name": { + "type": "string", + "example": "Customer Notifications" + }, + "optinKeywords": { + "type": "string", + "example": "YES, START, SUBSCRIBE" + }, + "optinMessage": { + "type": "string", + "example": "You have opted in to Acme Corp notifications. Reply STOP to opt out." + }, + "optoutKeywords": { + "type": "string", + "example": "STOP, UNSUBSCRIBE, END" + }, + "optoutMessage": { + "type": "string", + "example": "You have been unsubscribed. Reply START to opt back in." + }, + "privacyPolicyLink": { + "type": "string", + "example": "https://acmecorp.com/privacy" + }, + "termsAndConditionsLink": { + "type": "string", + "example": "https://acmecorp.com/terms" + }, + "type": { + "type": "string", + "example": "App" + }, + "useCases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "messagingUseCaseUs": { + "type": "string", + "example": "ACCOUNT_NOTIFICATION" + }, + "sampleMessages": { + "type": "array", + "items": { + "type": "string", + "example": "Hi {name}, your appointment is confirmed for {date} at {time}." + }, + "example": [ + "Hi {name}, your appointment is confirmed for {date} at {time}.", + "Your order #{order_id} has been shipped. Track at {url}" + ] + } + } + }, + "example": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Hi {name}, your appointment is confirmed for {date} at {time}.", + "Your order #{order_id} has been shipped. Track at {url}" + ] + } + ] + } + } + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Create a campaign for a profile's brand": { + "value": { + "campaign": { + "description": "Appointment reminders and account notifications", + "helpKeywords": "HELP, INFO, SUPPORT", + "helpMessage": "Reply STOP to unsubscribe or contact support@acmecorp.com", + "messageFlow": "User signs up on website and opts in to receive SMS notifications", + "name": "Customer Notifications", + "optinKeywords": "YES, START, SUBSCRIBE", + "optinMessage": "You have opted in to Acme Corp notifications. Reply STOP to opt out.", + "optoutKeywords": "STOP, UNSUBSCRIBE, END", + "optoutMessage": "You have been unsubscribed. Reply START to opt back in.", + "privacyPolicyLink": "https://acmecorp.com/privacy", + "termsAndConditionsLink": "https://acmecorp.com/terms", + "type": "App", + "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}" + ] + } + ] + }, + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "profileId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{profileId}}" + } + } + ] + }, + "/v3/profiles/{profileId}/campaigns/{campaignId}": { + "put": { + "tags": [ + "Profiles" + ], + "summary": "Update a campaign", + "description": "Updates an existing campaign under the brand of the specified profile. Cannot update campaigns that have already been submitted to TCR.", + "operationId": "updateACampaign", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "campaign": { + "type": "object", + "properties": { + "description": { + "type": "string", + "example": "Updated appointment reminders and account notifications" + }, + "helpKeywords": { + "nullable": true, + "example": null + }, + "helpMessage": { + "nullable": true, + "example": null + }, + "messageFlow": { + "type": "string", + "example": "User signs up on website and opts in to receive SMS notifications" + }, + "name": { + "type": "string", + "example": "Customer Notifications Updated" + }, + "optinKeywords": { + "nullable": true, + "example": null + }, + "optinMessage": { + "nullable": true, + "example": null + }, + "optoutKeywords": { + "nullable": true, + "example": null + }, + "optoutMessage": { + "nullable": true, + "example": null + }, + "privacyPolicyLink": { + "nullable": true, + "example": null + }, + "termsAndConditionsLink": { + "nullable": true, + "example": null + }, + "type": { + "type": "string", + "example": "App" + }, + "useCases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "messagingUseCaseUs": { + "type": "string", + "example": "ACCOUNT_NOTIFICATION" + }, + "sampleMessages": { + "type": "array", + "items": { + "type": "string", + "example": "Hi {name}, your appointment is confirmed for {date} at {time}." + }, + "example": [ + "Hi {name}, your appointment is confirmed for {date} at {time}.", + "Your order #{order_id} has been shipped. Track at {url}" + ] + } + } + }, + "example": [ + { + "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", + "sampleMessages": [ + "Hi {name}, your appointment is confirmed for {date} at {time}.", + "Your order #{order_id} has been shipped. Track at {url}" + ] + } + ] + } + } + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Update a campaign": { + "value": { + "campaign": { + "description": "Updated appointment reminders and account notifications", + "helpKeywords": null, + "helpMessage": null, + "messageFlow": "User signs up on website and opts in to receive SMS notifications", + "name": "Customer Notifications Updated", + "optinKeywords": null, + "optinMessage": null, + "optoutKeywords": null, + "optoutMessage": null, + "privacyPolicyLink": null, + "termsAndConditionsLink": null, + "type": "App", + "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}" + ] + } + ] + }, + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Profiles" + ], + "summary": "Delete a campaign", + "description": "Deletes a campaign by ID from the brand of the specified profile. The profile must belong to the authenticated organization.", + "operationId": "deleteACampaign", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Delete a campaign": { + "value": { + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "profileId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{profileId}}" + } + }, + { + "name": "campaignId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{campaignId}}" + } + } + ] + }, + "/v3/numbers/lookup/{phoneNumber}": { + "get": { + "tags": [ + "Numbers" + ], + "summary": "Get phone number details", + "description": "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.", + "operationId": "getPhoneNumberDetails", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "phoneNumber", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{phoneNumber}}" + } + } + ] + }, + "/v3/messages/{id}/activities": { + "get": { + "tags": [ + "Messages" + ], + "summary": "Get message activities", + "description": "Retrieves the activity log for a specific message. Activities track the message lifecycle including acceptance, processing, sending, delivery, and any errors.", + "operationId": "getMessageActivities", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/messages/{id}": { + "get": { + "tags": [ + "Messages" + ], + "summary": "Get message status", + "description": "Retrieves the current status and details of a message by ID. Includes delivery status, timestamps, and error information if applicable.", + "operationId": "getMessageStatus", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/messages": { + "post": { + "tags": [ + "Messages" + ], + "summary": "Send a message", + "description": "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.", + "operationId": "sendAMessage", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "channel": { + "type": "array", + "items": { + "type": "string", + "example": "sms" + }, + "example": [ + "sms", + "whatsapp" + ] + }, + "sandbox": { + "type": "boolean", + "example": false + }, + "template": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "7ba7b820-9dad-11d1-80b4-00c04fd430c8" + }, + "name": { + "type": "string", + "example": "order_confirmation" + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "John Doe" + }, + "order_id": { + "type": "string", + "example": "12345" + } + } + } + } + }, + "to": { + "type": "array", + "items": { + "type": "string", + "example": "+14155551234" + }, + "example": [ + "+14155551234", + "+14155555678" + ] + } + } + }, + "examples": { + "Send a message": { + "value": { + "channel": [ + "sms", + "whatsapp" + ], + "sandbox": false, + "template": { + "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", + "name": "order_confirmation", + "parameters": { + "name": "John Doe", + "order_id": "12345" + } + }, + "to": [ + "+14155551234", + "+14155555678" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v3/contacts": { + "get": { + "tags": [ + "Contacts" + ], + "summary": "Get contacts list", + "description": "Retrieves a paginated list of contacts for the authenticated customer. Supports filtering by search term, channel, or phone number.", + "operationId": "getContactsList", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "string", + "example": "1" + }, + "description": "Page number (1-indexed)" + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "string", + "example": "1" + }, + "description": "Number of items per page" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "example": "string" + }, + "description": "Optional search term for filtering contacts" + }, + { + "name": "channel", + "in": "query", + "schema": { + "type": "string", + "example": "string" + }, + "description": "Optional channel filter (sms, whatsapp)" + }, + { + "name": "phone", + "in": "query", + "schema": { + "type": "string", + "example": "string" + }, + "description": "Optional phone number filter (alternative to list view)" + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Contacts" + ], + "summary": "Create a contact", + "description": "Creates a new contact by phone number and associates it with the authenticated customer.", + "operationId": "createAContact", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "phone_number": { + "type": "string", + "example": "+1234567890" + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Create a contact": { + "value": { + "phone_number": "+1234567890", + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v3/contacts/{id}": { + "get": { + "tags": [ + "Contacts" + ], + "summary": "Get contact by ID", + "description": "Retrieves a specific contact by their unique identifier. Returns detailed contact information including phone formats, available channels, and opt-out status.", + "operationId": "getContactById", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Contacts" + ], + "summary": "Delete a contact", + "description": "Dissociates a contact from the authenticated customer. Inherited contacts cannot be deleted.", + "operationId": "deleteAContact", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Delete a contact": { + "value": { + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "patch": { + "tags": [ + "Contacts" + ], + "summary": "Update a contact", + "description": "Updates a contact's default channel and/or opt-out status. Inherited contacts cannot be updated.", + "operationId": "updateAContact", + "parameters": [ + { + "name": "idempotency-key", + "in": "header", + "schema": { + "type": "string", + "example": "string" + }, + "description": "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." + }, + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "default_channel": { + "type": "string", + "example": "whatsapp" + }, + "opt_out": { + "type": "boolean", + "example": false + }, + "sandbox": { + "type": "boolean", + "example": false + } + } + }, + "examples": { + "Update a contact": { + "value": { + "default_channel": "whatsapp", + "opt_out": false, + "sandbox": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "{{id}}" + } + } + ] + }, + "/v3/me": { + "get": { + "tags": [ + "Accounts" + ], + "summary": "Get authenticated account", + "description": "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.\n\n**Account types:**\n- `organization` — Has child profiles. The `profiles` array is populated.\n- `user` — Standalone account with no profiles.\n- `profile` — Child of an organization. Includes `organization_id`, `short_name`, `status`, and `settings`.\n\n**Channels:**\nThe `channels` object always includes `sms`, `whatsapp`, and `rcs`. Each channel has a `configured` boolean. Configured channels expose additional details such as `phone_number`.", + "operationId": "getAuthenticatedAccount", + "parameters": [ + { + "name": "x-profile-id", + "in": "header", + "schema": { + "type": "string", + "example": "{{$guid}}" + }, + "description": "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": { + "description": "" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "x-api-key", + "in": "header" + } + } + }, + "security": [ + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Webhooks", + "description": "Manage webhook endpoints that receive real-time event notifications for messages and templates.\n\nEvents are delivered as `POST` requests to your endpoint with a nested `{ field, timestamp, payload }` structure. Verify the `X-Webhook-Signature` header on every incoming request and use `X-Webhook-ID` for idempotency.\n\n📖 [Webhooks — Getting Started](https://docs.sent.dm/start/webhooks/getting-started)\n📖 [Event Types](https://docs.sent.dm/start/webhooks/event-types)\n📖 [Local Development](https://docs.sent.dm/start/webhooks/local-development)\n📖 [Handling Retries](https://docs.sent.dm/start/webhooks/handling-retries)\n📖 [Signature Verification](https://docs.sent.dm/start/webhooks/signature-verification)\n📖 [Production Checklist](https://docs.sent.dm/start/webhooks/production-checklist)" + }, + { + "name": "Users", + "description": "Manage user accounts and roles within your organization. Invite new users, update roles (`admin`, `member`, `billing`), or remove access.\n\n📖 [Dashboard Walkthrough](https://docs.sent.dm/start/quickstart/dashboard-walkthrough)" + }, + { + "name": "Templates", + "description": "Create and manage reusable message templates for SMS and WhatsApp. Templates go through a lifecycle: `DRAFT → PENDING → APPROVED / REJECTED`. WhatsApp templates require Meta approval before use on that channel; SMS delivery is available immediately.\n\n📖 [Working with Templates](https://docs.sent.dm/start/guides/working-with-templates)\n📖 [First Template Quickstart](https://docs.sent.dm/start/quickstart/first-template)" + }, + { + "name": "Profiles", + "description": "Profiles enable multi-tenant architectures — each profile is an isolated messaging identity with its own contacts, templates, and webhooks. Organization API keys can act on behalf of a profile by passing the `x-profile-id` header.\n\nProfiles also hold 10DLC brand and campaign registrations required for US SMS compliance.\n\n📖 [Multi-Tenant Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures)\n📖 [Compliance & Regulations](https://docs.sent.dm/start/advanced/compliance-regulations)" + }, + { + "name": "Numbers", + "description": "Look up a phone number to check its validity and available channels (SMS / WhatsApp) before sending. Useful for pre-validating recipients without creating a contact.\n\n📖 [Managing Contacts](https://docs.sent.dm/start/guides/managing-contacts)" + }, + { + "name": "Messages", + "description": "Send SMS and WhatsApp messages using pre-approved templates. A single request can target up to **1,000 recipients**; separate messages are created per (recipient, channel) pair — there is no implicit fallback when multiple channels are specified.\n\nPoll `/v3/messages/{id}/activities` or use webhooks to track delivery status (`QUEUED → SENT → DELIVERED → READ / FAILED`).\n\n📖 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages)\n📖 [Message Responses](https://docs.sent.dm/start/guides/message-responses)\n📖 [Message Status Tracking](https://docs.sent.dm/start/guides/message-status-tracking)\n📖 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations)" + }, + { + "name": "Contacts", + "description": "Contacts are validated, channel-aware communication endpoints. Creating a contact triggers real-time validation across SMS and WhatsApp, caching channel availability for optimized future routing.\n\nContacts are created lazily on first message send, or explicitly via `POST /v3/contacts`.\n\n📖 [Managing Contacts](https://docs.sent.dm/start/guides/managing-contacts)" + }, + { + "name": "Accounts", + "description": "Retrieve details about the authenticated account — balance, plan, and configuration.\n\n📖 [Account Setup](https://docs.sent.dm/start/quickstart/account-setup)" + } + ] +} diff --git a/jvagent/action/sentdm_broadcast/docs/openapi.yaml b/jvagent/action/sentdm_broadcast/docs/openapi.yaml new file mode 100644 index 00000000..81729273 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/docs/openapi.yaml @@ -0,0 +1,2691 @@ +openapi: 3.0.3 +info: + title: SentDM - Public v3 API + description: >- + # Sent DM API v3 + + + Programmatic SMS and WhatsApp messaging — one API for sending messages, + managing templates, contacts, webhooks, and compliance. + + + ## Authentication + + + All requests require the `x-api-key` header. Set your key once in the + **Variables** tab of this collection (`apiKey`) and it will be applied to + every request automatically. + + + ## Sandbox Mode + + + Every mutation endpoint accepts `"sandbox": true` in the request body. The + API validates and returns a realistic response without executing any side + effects — no messages sent, no data written. Useful for CI/CD and + integration testing. + + + ## Useful Links + + + - 📚 [API Reference](https://docs.sent.dm/reference/api) + + - 🚀 [Quickstart — Send your first + message](https://docs.sent.dm/start/quickstart/first-message) + + - 📝 [Working with + Templates](https://docs.sent.dm/start/guides/working-with-templates) + + - 📨 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages) + + - 🔔 [Webhooks — Getting + Started](https://docs.sent.dm/start/webhooks/getting-started) + + - 🔔 [Webhook Event Types](https://docs.sent.dm/start/webhooks/event-types) + + - ⚙️ [Webhook Local + Development](https://docs.sent.dm/start/webhooks/local-development) + + - 🔁 [Handling + Retries](https://docs.sent.dm/start/webhooks/handling-retries) + + - 🧪 [Testing & + Debugging](https://docs.sent.dm/start/guides/testing-debugging) + + - ❌ [Error Handling](https://docs.sent.dm/start/guides/error-handling) + + - 📦 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations) + + - 🏢 [Multi-Tenant + Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures) + + + ## Rate Limits + + + | Endpoint type | Limit | + + | --- | --- | + + | Standard | 200 req/min | + + | Sensitive (rotate-secret, test-webhook) | 10 req/min | + + + On `429`, check the `Retry-After` response header. + version: 1.0.0 + contact: {} +servers: + - url: https://api.sent.dm +paths: + /v3/webhooks: + get: + tags: + - Webhooks + summary: Get webhooks list + description: Retrieves a paginated list of webhooks for the authenticated customer. + operationId: getWebhooksList + parameters: + - name: page + in: query + schema: + type: string + example: '1' + - name: page_size + in: query + schema: + type: string + example: '1' + - name: search + in: query + schema: + type: string + example: string + - name: is_active + in: query + schema: + type: string + example: '' + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + post: + tags: + - Webhooks + summary: Create a webhook + description: Creates a new webhook endpoint for the authenticated customer. + operationId: createAWebhook + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + display_name: + type: string + example: Order Notifications + endpoint_url: + type: string + example: https://example.com/webhooks/orders + event_types: + type: array + items: + type: string + example: messages + example: + - messages + - templates + retry_count: + type: number + example: 3 + sandbox: + type: boolean + example: false + timeout_seconds: + type: number + example: 30 + examples: + Create a webhook: + value: + display_name: Order Notifications + endpoint_url: https://example.com/webhooks/orders + event_types: + - messages + - templates + retry_count: 3 + sandbox: false + timeout_seconds: 30 + responses: + '200': + description: '' + /v3/webhooks/{id}: + get: + tags: + - Webhooks + summary: Get a webhook + description: Retrieves a single webhook by ID for the authenticated customer. + operationId: getAWebhook + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + put: + tags: + - Webhooks + summary: Update a webhook + description: Updates an existing webhook for the authenticated customer. + operationId: updateAWebhook + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + display_name: + type: string + example: Updated Order Notifications + endpoint_url: + type: string + example: https://example.com/webhooks/orders-v2 + event_types: + type: array + items: + type: string + example: messages + example: + - messages + - templates + retry_count: + type: number + example: 5 + sandbox: + type: boolean + example: false + timeout_seconds: + type: number + example: 60 + examples: + Update a webhook: + value: + display_name: Updated Order Notifications + endpoint_url: https://example.com/webhooks/orders-v2 + event_types: + - messages + - templates + retry_count: 5 + sandbox: false + timeout_seconds: 60 + responses: + '200': + description: '' + delete: + tags: + - Webhooks + summary: Delete a webhook + description: Deletes a webhook for the authenticated customer. + operationId: deleteAWebhook + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/webhooks/{id}/events: + get: + tags: + - Webhooks + summary: Get webhook events + description: Retrieves a paginated list of delivery events for the specified webhook. + operationId: getWebhookEvents + parameters: + - name: page + in: query + schema: + type: string + example: '1' + - name: page_size + in: query + schema: + type: string + example: '1' + - name: search + in: query + schema: + type: string + example: string + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/webhooks/event-types: + get: + tags: + - Webhooks + summary: Get available webhook event types + description: Retrieves all available webhook event types that can be subscribed to. + operationId: getAvailableWebhookEventTypes + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + /v3/webhooks/{id}/rotate-secret: + post: + tags: + - Webhooks + summary: Rotate webhook signing secret + description: >- + Generates a new signing secret for the specified webhook. The old secret + is immediately invalidated. + operationId: rotateWebhookSigningSecret + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + sandbox: + type: boolean + example: false + examples: + Rotate webhook signing secret: + value: + sandbox: false + responses: + '200': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/webhooks/{id}/test: + post: + tags: + - Webhooks + summary: Test a webhook + description: >- + Sends a test event to the specified webhook endpoint to verify + connectivity. + operationId: testAWebhook + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + event_type: + type: string + example: message.sent + sandbox: + type: boolean + example: false + examples: + Test a webhook: + value: + event_type: message.sent + sandbox: false + responses: + '200': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/webhooks/{id}/toggle-status: + patch: + tags: + - Webhooks + summary: Toggle webhook status + description: Activates or deactivates a webhook for the authenticated customer. + operationId: toggleWebhookStatus + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + is_active: + type: boolean + example: false + sandbox: + type: boolean + example: false + examples: + Toggle webhook status: + value: + is_active: false + sandbox: false + responses: + '200': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/users/{userId}: + get: + tags: + - Users + summary: Get user by ID + description: >- + Retrieves detailed information about a specific user in an organization + or profile. Requires developer role or higher. + operationId: getUserById + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + delete: + tags: + - Users + summary: Remove user + description: >- + Removes a user's access to an organization or profile. Requires admin + role. You cannot remove yourself or remove the last admin. + operationId: removeUser + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + sandbox: + type: boolean + example: false + examples: + Remove user: + value: + sandbox: false + responses: + '200': + description: '' + patch: + tags: + - Users + summary: Update user role + description: >- + Updates a user's role in the organization or profile. Requires admin + role. You cannot change your own role or demote the last admin. + operationId: updateUserRole + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + role: + type: string + example: billing + sandbox: + type: boolean + example: false + examples: + Update user role: + value: + role: billing + sandbox: false + responses: + '200': + description: '' + parameters: + - name: userId + in: path + required: true + schema: + type: string + example: '{{userId}}' + /v3/users: + get: + tags: + - Users + summary: List users + description: >- + 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. + operationId: listUsers + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + post: + tags: + - Users + summary: Invite a user + description: >- + 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. + operationId: inviteAUser + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + example: newuser@example.com + name: + type: string + example: New User + role: + type: string + example: developer + sandbox: + type: boolean + example: false + examples: + Invite a user: + value: + email: newuser@example.com + name: New User + role: developer + sandbox: false + responses: + '200': + description: '' + /v3/templates: + get: + tags: + - Templates + summary: Get templates list + description: >- + Retrieves a paginated list of message templates for the authenticated + customer. Supports filtering by status, category, and search term. + operationId: getTemplatesList + parameters: + - name: page + in: query + schema: + type: string + example: '1' + description: Page number (1-indexed) + - name: page_size + in: query + schema: + type: string + example: '1' + description: Number of items per page + - name: search + in: query + schema: + type: string + example: string + description: Optional search term for filtering templates + - name: status + in: query + schema: + type: string + example: string + description: 'Optional status filter: APPROVED, PENDING, REJECTED' + - name: category + in: query + schema: + type: string + example: string + description: 'Optional category filter: MARKETING, UTILITY, AUTHENTICATION' + - name: is_welcome_playground + in: query + schema: + type: string + example: '' + description: Optional filter by welcome playground flag + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + post: + tags: + - Templates + summary: Create a new template + description: >- + 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. + operationId: createANewTemplate + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + category: + type: string + example: MARKETING + definition: + type: object + properties: + authenticationConfig: + nullable: true + example: null + body: + type: object + properties: + multiChannel: + type: object + properties: + template: + type: string + example: Hello {{0:variable}}! Welcome to {{1:variable}}. + type: + nullable: true + example: null + variables: + type: array + items: + type: object + properties: + id: + type: number + example: 0 + name: + type: string + example: name + props: + type: object + properties: + alt: + nullable: true + example: null + mediaType: + nullable: true + example: null + regex: + nullable: true + example: null + sample: + type: string + example: John + shortUrl: + nullable: true + example: null + url: + nullable: true + example: null + variableType: + type: string + example: text + type: + type: string + example: variable + example: + - id: 0 + name: name + props: + alt: null + mediaType: null + regex: null + sample: John + shortUrl: null + url: null + variableType: text + type: variable + - id: 1 + name: company + props: + alt: null + mediaType: null + regex: null + sample: SentDM + shortUrl: null + url: null + variableType: text + type: variable + sms: + nullable: true + example: null + whatsapp: + nullable: true + example: null + buttons: + nullable: true + example: null + definitionVersion: + type: string + example: '1.0' + footer: + nullable: true + example: null + header: + nullable: true + example: null + language: + type: string + example: en_US + sandbox: + type: boolean + example: false + submit_for_review: + type: boolean + example: false + examples: + Create a new template: + value: + category: MARKETING + definition: + authenticationConfig: null + body: + multiChannel: + template: Hello {{0:variable}}! Welcome to {{1:variable}}. + type: null + variables: + - id: 0 + name: name + props: + alt: null + mediaType: null + regex: null + sample: John + shortUrl: null + url: null + variableType: text + type: variable + - id: 1 + name: company + props: + alt: null + mediaType: null + regex: null + sample: SentDM + shortUrl: null + url: null + variableType: text + type: variable + sms: null + whatsapp: null + buttons: null + definitionVersion: '1.0' + footer: null + header: null + language: en_US + sandbox: false + submit_for_review: false + responses: + '200': + description: '' + /v3/templates/{id}: + get: + tags: + - Templates + summary: Get template by ID + description: >- + Retrieves a specific template by its ID. Returns template details + including name, category, language, status, and definition. + operationId: getTemplateById + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + put: + tags: + - Templates + summary: Update a template + description: >- + Updates an existing template's name, category, language, definition, or + submits it for review. + operationId: updateATemplate + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + category: + type: string + example: MARKETING + definition: + type: object + properties: + authenticationConfig: + type: object + properties: + addSecurityRecommendation: + type: boolean + example: false + codeExpirationMinutes: + type: number + example: 1 + buttons: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + type: + type: string + example: string + example: + - id: 1 + type: string + definitionVersion: + type: string + example: string + footer: + type: object + properties: + template: + type: string + example: string + type: + type: string + example: string + variables: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: string + props: + type: object + properties: {} + type: + type: string + example: string + example: + - id: 1 + name: string + props: {} + type: string + header: + type: object + properties: + template: + type: string + example: string + type: + type: string + example: string + variables: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: string + props: + type: object + properties: {} + type: + type: string + example: string + example: + - id: 1 + name: string + props: {} + type: string + name: + type: string + example: Updated Welcome Message + sandbox: + type: boolean + example: false + submit_for_review: + type: boolean + example: false + examples: + Update a template: + value: + category: MARKETING + definition: + authenticationConfig: + addSecurityRecommendation: false + codeExpirationMinutes: 1 + buttons: + - id: 1 + type: string + definitionVersion: string + footer: + template: string + type: string + variables: + - id: 1 + name: string + props: {} + type: string + header: + template: string + type: string + variables: + - id: 1 + name: string + props: {} + type: string + name: Updated Welcome Message + sandbox: false + submit_for_review: false + responses: + '200': + description: '' + delete: + tags: + - Templates + summary: Delete a template + description: >- + Deletes a template by ID. Optionally, you can also delete the template + from WhatsApp/Meta by setting delete_from_meta=true. + operationId: deleteATemplate + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + delete_from_meta: + type: boolean + example: false + sandbox: + type: boolean + example: false + examples: + Delete a template: + value: + delete_from_meta: false + sandbox: false + responses: + '200': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/profiles/{profileId}/complete: + post: + tags: + - Profiles + summary: Complete profile setup + description: >- + Final step in profile compliance workflow. Validates all prerequisites + (general data, brand, campaigns), connects profile to Telnyx/WhatsApp, + and sets status based on configuration. The process runs in the + background and calls the provided webhook URL when finished. + + Prerequisites: + - Profile must be completed + - If inheritTcrBrand=false: Profile must have existing brand + - If inheritTcrBrand=true: Parent must have existing brand + - If TCR application: Must have at least one campaign (own or inherited) + - If inheritTcrCampaign=false: Profile should have campaigns + - If inheritTcrCampaign=true: Parent must have campaigns + + Status Logic: + - If both SMS and WhatsApp channels are missing → SUBMITTED + - If TCR application and not inheriting brand/campaigns → SUBMITTED + - If non-TCR with destination country (IsMain=true) → SUBMITTED + - Otherwise → COMPLETED + operationId: completeProfileSetup + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + sandbox: + type: boolean + example: false + webHookUrl: + type: string + example: https://your-app.com/webhook/profile-complete + examples: + Complete profile setup: + value: + sandbox: false + webHookUrl: https://your-app.com/webhook/profile-complete + responses: + '200': + description: '' + parameters: + - name: profileId + in: path + required: true + schema: + type: string + example: '{{profileId}}' + /v3/profiles: + get: + tags: + - Profiles + summary: List profiles in organization + description: >- + 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. + operationId: listProfilesInOrganization + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + post: + tags: + - Profiles + summary: Create a new profile + description: >- + 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. + operationId: createANewProfile + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + allow_contact_sharing: + type: boolean + example: true + allow_template_sharing: + type: boolean + example: false + billing_contact: + type: object + properties: + address: + type: string + example: string + email: + type: string + example: string + name: + type: string + example: string + phone: + type: string + example: string + billing_model: + type: string + example: profile + brand: + type: object + properties: + business: + type: object + properties: + city: + type: string + example: string + country: + type: string + example: string + countryOfRegistration: + type: string + example: string + legalName: + type: string + example: string + postalCode: + type: string + example: string + state: + type: string + example: string + street: + type: string + example: string + taxId: + type: string + example: string + taxIdType: + type: string + example: string + url: + type: string + example: https://example.com/webhook + description: + type: string + example: Sales department sender profile + icon: + type: string + example: https://example.com/sales-icon.png + inherit_contacts: + type: boolean + example: true + inherit_tcr_brand: + type: boolean + example: false + inherit_tcr_campaign: + type: boolean + example: false + inherit_templates: + type: boolean + example: true + name: + type: string + example: Sales Team + payment_details: + type: object + properties: + card_number: + type: string + example: string + cvc: + type: string + example: string + expiry: + type: string + example: string + zip_code: + type: string + example: string + sandbox: + type: boolean + example: false + short_name: + type: string + example: SALES + whatsapp_business_account: + type: object + properties: + access_token: + type: string + example: string + phone_number_id: + type: string + example: string + waba_id: + type: string + example: string + examples: + Create a new profile: + value: + allow_contact_sharing: true + allow_template_sharing: false + billing_contact: + address: string + email: string + name: string + phone: string + billing_model: profile + brand: + business: + city: string + country: string + countryOfRegistration: string + legalName: string + postalCode: string + state: string + street: string + taxId: string + taxIdType: string + url: https://example.com/webhook + description: Sales department sender profile + icon: https://example.com/sales-icon.png + inherit_contacts: true + inherit_tcr_brand: false + inherit_tcr_campaign: false + inherit_templates: true + name: Sales Team + payment_details: + card_number: string + cvc: string + expiry: string + zip_code: string + sandbox: false + short_name: SALES + whatsapp_business_account: + access_token: string + phone_number_id: string + waba_id: string + responses: + '200': + description: '' + /v3/profiles/{profileId}: + get: + tags: + - Profiles + summary: Get profile by ID + description: >- + Retrieves detailed information about a specific sender profile within an + organization, including brand and KYC information if a brand has been + configured. + operationId: getProfileById + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + delete: + tags: + - Profiles + summary: Delete a profile + description: >- + Soft deletes a sender profile. The profile will be marked as deleted but + data is retained. Requires admin role in the organization. + operationId: deleteAProfile + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + sandbox: + type: boolean + example: false + examples: + Delete a profile: + value: + sandbox: false + responses: + '200': + description: '' + patch: + tags: + - Profiles + summary: Update profile settings + description: >- + 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. + operationId: updateProfileSettings + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + allow_contact_sharing: + type: boolean + example: true + billing_contact: + type: object + properties: + address: + type: string + example: string + email: + type: string + example: string + name: + type: string + example: string + phone: + type: string + example: string + billing_model: + type: string + example: organization + brand: + type: object + properties: + business: + type: object + properties: + city: + type: string + example: string + country: + type: string + example: string + countryOfRegistration: + type: string + example: string + legalName: + type: string + example: string + postalCode: + type: string + example: string + state: + type: string + example: string + street: + type: string + example: string + taxId: + type: string + example: string + taxIdType: + type: string + example: string + url: + type: string + example: https://example.com/webhook + description: + type: string + example: Updated sales department sender profile + name: + type: string + example: Sales Team - Updated + payment_details: + type: object + properties: + card_number: + type: string + example: string + cvc: + type: string + example: string + expiry: + type: string + example: string + zip_code: + type: string + example: string + sandbox: + type: boolean + example: false + short_name: + type: string + example: SALES + examples: + Update profile settings: + value: + allow_contact_sharing: true + billing_contact: + address: string + email: string + name: string + phone: string + billing_model: organization + brand: + business: + city: string + country: string + countryOfRegistration: string + legalName: string + postalCode: string + state: string + street: string + taxId: string + taxIdType: string + url: https://example.com/webhook + description: Updated sales department sender profile + name: Sales Team - Updated + payment_details: + card_number: string + cvc: string + expiry: string + zip_code: string + sandbox: false + short_name: SALES + responses: + '200': + description: '' + parameters: + - name: profileId + in: path + required: true + schema: + type: string + example: '{{profileId}}' + /v3/profiles/{profileId}/campaigns: + get: + tags: + - Profiles + summary: Get campaigns for a profile's brand + description: >- + Retrieves all campaigns linked to the profile's brand, including use + cases and sample messages. Returns inherited campaigns if + inherit_tcr_campaign=true. + operationId: getCampaignsForAProfileSBrand + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + post: + tags: + - Profiles + summary: Create a campaign for a profile's brand + description: >- + Creates a new campaign scoped under the brand of the specified profile. + Each campaign must include at least one use case with sample messages. + operationId: createACampaignForAProfileSBrand + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + campaign: + type: object + properties: + description: + type: string + example: Appointment reminders and account notifications + helpKeywords: + type: string + example: HELP, INFO, SUPPORT + helpMessage: + type: string + example: >- + Reply STOP to unsubscribe or contact + support@acmecorp.com + messageFlow: + type: string + example: >- + User signs up on website and opts in to receive SMS + notifications + name: + type: string + example: Customer Notifications + optinKeywords: + type: string + example: YES, START, SUBSCRIBE + optinMessage: + type: string + example: >- + You have opted in to Acme Corp notifications. Reply STOP + to opt out. + optoutKeywords: + type: string + example: STOP, UNSUBSCRIBE, END + optoutMessage: + type: string + example: You have been unsubscribed. Reply START to opt back in. + privacyPolicyLink: + type: string + example: https://acmecorp.com/privacy + termsAndConditionsLink: + type: string + example: https://acmecorp.com/terms + type: + type: string + example: App + useCases: + type: array + items: + type: object + properties: + messagingUseCaseUs: + type: string + example: ACCOUNT_NOTIFICATION + sampleMessages: + type: array + items: + type: string + example: >- + Hi {name}, your appointment is confirmed for + {date} at {time}. + example: + - >- + Hi {name}, your appointment is confirmed for + {date} at {time}. + - >- + Your order #{order_id} has been shipped. Track + at {url} + example: + - messagingUseCaseUs: ACCOUNT_NOTIFICATION + sampleMessages: + - >- + Hi {name}, your appointment is confirmed for + {date} at {time}. + - >- + Your order #{order_id} has been shipped. Track at + {url} + sandbox: + type: boolean + example: false + examples: + Create a campaign for a profile's brand: + value: + campaign: + description: Appointment reminders and account notifications + helpKeywords: HELP, INFO, SUPPORT + helpMessage: Reply STOP to unsubscribe or contact support@acmecorp.com + messageFlow: >- + User signs up on website and opts in to receive SMS + notifications + name: Customer Notifications + optinKeywords: YES, START, SUBSCRIBE + optinMessage: >- + You have opted in to Acme Corp notifications. Reply STOP + to opt out. + optoutKeywords: STOP, UNSUBSCRIBE, END + optoutMessage: You have been unsubscribed. Reply START to opt back in. + privacyPolicyLink: https://acmecorp.com/privacy + termsAndConditionsLink: https://acmecorp.com/terms + type: App + 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} + sandbox: false + responses: + '200': + description: '' + parameters: + - name: profileId + in: path + required: true + schema: + type: string + example: '{{profileId}}' + /v3/profiles/{profileId}/campaigns/{campaignId}: + put: + tags: + - Profiles + summary: Update a campaign + description: >- + Updates an existing campaign under the brand of the specified profile. + Cannot update campaigns that have already been submitted to TCR. + operationId: updateACampaign + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + campaign: + type: object + properties: + description: + type: string + example: Updated appointment reminders and account notifications + helpKeywords: + nullable: true + example: null + helpMessage: + nullable: true + example: null + messageFlow: + type: string + example: >- + User signs up on website and opts in to receive SMS + notifications + name: + type: string + example: Customer Notifications Updated + optinKeywords: + nullable: true + example: null + optinMessage: + nullable: true + example: null + optoutKeywords: + nullable: true + example: null + optoutMessage: + nullable: true + example: null + privacyPolicyLink: + nullable: true + example: null + termsAndConditionsLink: + nullable: true + example: null + type: + type: string + example: App + useCases: + type: array + items: + type: object + properties: + messagingUseCaseUs: + type: string + example: ACCOUNT_NOTIFICATION + sampleMessages: + type: array + items: + type: string + example: >- + Hi {name}, your appointment is confirmed for + {date} at {time}. + example: + - >- + Hi {name}, your appointment is confirmed for + {date} at {time}. + - >- + Your order #{order_id} has been shipped. Track + at {url} + example: + - messagingUseCaseUs: ACCOUNT_NOTIFICATION + sampleMessages: + - >- + Hi {name}, your appointment is confirmed for + {date} at {time}. + - >- + Your order #{order_id} has been shipped. Track at + {url} + sandbox: + type: boolean + example: false + examples: + Update a campaign: + value: + campaign: + description: Updated appointment reminders and account notifications + helpKeywords: null + helpMessage: null + messageFlow: >- + User signs up on website and opts in to receive SMS + notifications + name: Customer Notifications Updated + optinKeywords: null + optinMessage: null + optoutKeywords: null + optoutMessage: null + privacyPolicyLink: null + termsAndConditionsLink: null + type: App + 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} + sandbox: false + responses: + '200': + description: '' + delete: + tags: + - Profiles + summary: Delete a campaign + description: >- + Deletes a campaign by ID from the brand of the specified profile. The + profile must belong to the authenticated organization. + operationId: deleteACampaign + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + sandbox: + type: boolean + example: false + examples: + Delete a campaign: + value: + sandbox: false + responses: + '200': + description: '' + parameters: + - name: profileId + in: path + required: true + schema: + type: string + example: '{{profileId}}' + - name: campaignId + in: path + required: true + schema: + type: string + example: '{{campaignId}}' + /v3/numbers/lookup/{phoneNumber}: + get: + tags: + - Numbers + summary: Get phone number details + description: >- + 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. + operationId: getPhoneNumberDetails + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + parameters: + - name: phoneNumber + in: path + required: true + schema: + type: string + example: '{{phoneNumber}}' + /v3/messages/{id}/activities: + get: + tags: + - Messages + summary: Get message activities + description: >- + Retrieves the activity log for a specific message. Activities track the + message lifecycle including acceptance, processing, sending, delivery, + and any errors. + operationId: getMessageActivities + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/messages/{id}: + get: + tags: + - Messages + summary: Get message status + description: >- + Retrieves the current status and details of a message by ID. Includes + delivery status, timestamps, and error information if applicable. + operationId: getMessageStatus + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/messages: + post: + tags: + - Messages + summary: Send a message + description: >- + 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. + operationId: sendAMessage + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + channel: + type: array + items: + type: string + example: sms + example: + - sms + - whatsapp + sandbox: + type: boolean + example: false + template: + type: object + properties: + id: + type: string + example: 7ba7b820-9dad-11d1-80b4-00c04fd430c8 + name: + type: string + example: order_confirmation + parameters: + type: object + properties: + name: + type: string + example: John Doe + order_id: + type: string + example: '12345' + to: + type: array + items: + type: string + example: '+14155551234' + example: + - '+14155551234' + - '+14155555678' + examples: + Send a message: + value: + channel: + - sms + - whatsapp + sandbox: false + template: + id: 7ba7b820-9dad-11d1-80b4-00c04fd430c8 + name: order_confirmation + parameters: + name: John Doe + order_id: '12345' + to: + - '+14155551234' + - '+14155555678' + responses: + '200': + description: '' + /v3/contacts: + get: + tags: + - Contacts + summary: Get contacts list + description: >- + Retrieves a paginated list of contacts for the authenticated customer. + Supports filtering by search term, channel, or phone number. + operationId: getContactsList + parameters: + - name: page + in: query + schema: + type: string + example: '1' + description: Page number (1-indexed) + - name: page_size + in: query + schema: + type: string + example: '1' + description: Number of items per page + - name: search + in: query + schema: + type: string + example: string + description: Optional search term for filtering contacts + - name: channel + in: query + schema: + type: string + example: string + description: Optional channel filter (sms, whatsapp) + - name: phone + in: query + schema: + type: string + example: string + description: Optional phone number filter (alternative to list view) + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + post: + tags: + - Contacts + summary: Create a contact + description: >- + Creates a new contact by phone number and associates it with the + authenticated customer. + operationId: createAContact + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + phone_number: + type: string + example: '+1234567890' + sandbox: + type: boolean + example: false + examples: + Create a contact: + value: + phone_number: '+1234567890' + sandbox: false + responses: + '200': + description: '' + /v3/contacts/{id}: + get: + tags: + - Contacts + summary: Get contact by ID + description: >- + Retrieves a specific contact by their unique identifier. Returns + detailed contact information including phone formats, available + channels, and opt-out status. + operationId: getContactById + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' + delete: + tags: + - Contacts + summary: Delete a contact + description: >- + Dissociates a contact from the authenticated customer. Inherited + contacts cannot be deleted. + operationId: deleteAContact + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + sandbox: + type: boolean + example: false + examples: + Delete a contact: + value: + sandbox: false + responses: + '200': + description: '' + patch: + tags: + - Contacts + summary: Update a contact + description: >- + Updates a contact's default channel and/or opt-out status. Inherited + contacts cannot be updated. + operationId: updateAContact + parameters: + - name: idempotency-key + in: header + schema: + type: string + example: string + description: >- + 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. + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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. + requestBody: + content: + application/json: + schema: + type: object + properties: + default_channel: + type: string + example: whatsapp + opt_out: + type: boolean + example: false + sandbox: + type: boolean + example: false + examples: + Update a contact: + value: + default_channel: whatsapp + opt_out: false + sandbox: false + responses: + '200': + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '{{id}}' + /v3/me: + get: + tags: + - Accounts + summary: Get authenticated account + description: >- + 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`. + operationId: getAuthenticatedAccount + parameters: + - name: x-profile-id + in: header + schema: + type: string + example: '{{$guid}}' + description: >- + 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': + description: '' +components: + securitySchemes: + apiKey: + type: apiKey + name: x-api-key + in: header +security: + - apiKey: [] +tags: + - name: Webhooks + description: >- + Manage webhook endpoints that receive real-time event notifications for + messages and templates. + + + Events are delivered as `POST` requests to your endpoint with a nested `{ + field, timestamp, payload }` structure. Verify the `X-Webhook-Signature` + header on every incoming request and use `X-Webhook-ID` for idempotency. + + + 📖 [Webhooks — Getting + Started](https://docs.sent.dm/start/webhooks/getting-started) + + 📖 [Event Types](https://docs.sent.dm/start/webhooks/event-types) + + 📖 [Local + Development](https://docs.sent.dm/start/webhooks/local-development) + + 📖 [Handling + Retries](https://docs.sent.dm/start/webhooks/handling-retries) + + 📖 [Signature + Verification](https://docs.sent.dm/start/webhooks/signature-verification) + + 📖 [Production + Checklist](https://docs.sent.dm/start/webhooks/production-checklist) + - name: Users + description: >- + Manage user accounts and roles within your organization. Invite new users, + update roles (`admin`, `member`, `billing`), or remove access. + + + 📖 [Dashboard + Walkthrough](https://docs.sent.dm/start/quickstart/dashboard-walkthrough) + - name: Templates + description: >- + Create and manage reusable message templates for SMS and WhatsApp. + Templates go through a lifecycle: `DRAFT → PENDING → APPROVED / REJECTED`. + WhatsApp templates require Meta approval before use on that channel; SMS + delivery is available immediately. + + + 📖 [Working with + Templates](https://docs.sent.dm/start/guides/working-with-templates) + + 📖 [First Template + Quickstart](https://docs.sent.dm/start/quickstart/first-template) + - name: Profiles + description: >- + Profiles enable multi-tenant architectures — each profile is an isolated + messaging identity with its own contacts, templates, and webhooks. + Organization API keys can act on behalf of a profile by passing the + `x-profile-id` header. + + + Profiles also hold 10DLC brand and campaign registrations required for US + SMS compliance. + + + 📖 [Multi-Tenant + Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures) + + 📖 [Compliance & + Regulations](https://docs.sent.dm/start/advanced/compliance-regulations) + - name: Numbers + description: >- + Look up a phone number to check its validity and available channels (SMS / + WhatsApp) before sending. Useful for pre-validating recipients without + creating a contact. + + + 📖 [Managing + Contacts](https://docs.sent.dm/start/guides/managing-contacts) + - name: Messages + description: >- + Send SMS and WhatsApp messages using pre-approved templates. A single + request can target up to **1,000 recipients**; separate messages are + created per (recipient, channel) pair — there is no implicit fallback when + multiple channels are specified. + + + Poll `/v3/messages/{id}/activities` or use webhooks to track delivery + status (`QUEUED → SENT → DELIVERED → READ / FAILED`). + + + 📖 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages) + + 📖 [Message + Responses](https://docs.sent.dm/start/guides/message-responses) + + 📖 [Message Status + Tracking](https://docs.sent.dm/start/guides/message-status-tracking) + + 📖 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations) + - name: Contacts + description: >- + Contacts are validated, channel-aware communication endpoints. Creating a + contact triggers real-time validation across SMS and WhatsApp, caching + channel availability for optimized future routing. + + + Contacts are created lazily on first message send, or explicitly via `POST + /v3/contacts`. + + + 📖 [Managing + Contacts](https://docs.sent.dm/start/guides/managing-contacts) + - name: Accounts + description: >- + Retrieve details about the authenticated account — balance, plan, and + configuration. + + + 📖 [Account Setup](https://docs.sent.dm/start/quickstart/account-setup) diff --git a/jvagent/action/sentdm_broadcast/docs/postman.json b/jvagent/action/sentdm_broadcast/docs/postman.json new file mode 100644 index 00000000..1dc8e200 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/docs/postman.json @@ -0,0 +1,2074 @@ +{ + "info": { + "_postman_id": "c01e1c57-126b-4465-83cf-9faabb182861", + "name": "SentDM - Public v3 API", + "description": "# Sent DM API v3\n\nProgrammatic SMS and WhatsApp messaging — one API for sending messages, managing templates, contacts, webhooks, and compliance.\n\n## Authentication\n\nAll requests require the `x-api-key` header. Set your key once in the **Variables** tab of this collection (`apiKey`) and it will be applied to every request automatically.\n\n## Sandbox Mode\n\nEvery mutation endpoint accepts `\"sandbox\": true` in the request body. The API validates and returns a realistic response without executing any side effects — no messages sent, no data written. Useful for CI/CD and integration testing.\n\n## Useful Links\n\n- 📚 [API Reference](https://docs.sent.dm/reference/api)\n \n- 🚀 [Quickstart — Send your first message](https://docs.sent.dm/start/quickstart/first-message)\n \n- 📝 [Working with Templates](https://docs.sent.dm/start/guides/working-with-templates)\n \n- 📨 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages)\n \n- 🔔 [Webhooks — Getting Started](https://docs.sent.dm/start/webhooks/getting-started)\n \n- 🔔 [Webhook Event Types](https://docs.sent.dm/start/webhooks/event-types)\n \n- ⚙️ [Webhook Local Development](https://docs.sent.dm/start/webhooks/local-development)\n \n- 🔁 [Handling Retries](https://docs.sent.dm/start/webhooks/handling-retries)\n \n- 🧪 [Testing & Debugging](https://docs.sent.dm/start/guides/testing-debugging)\n \n- ❌ [Error Handling](https://docs.sent.dm/start/guides/error-handling)\n \n- 📦 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations)\n \n- 🏢 [Multi-Tenant Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures)\n \n\n## Rate Limits\n\n| Endpoint type | Limit |\n| --- | --- |\n| Standard | 200 req/min |\n| Sensitive (rotate-secret, test-webhook) | 10 req/min |\n\nOn `429`, check the `Retry-After` response header.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "209920", + "_collection_link": "https://go.postman.co/collection/48394910-c01e1c57-126b-4465-83cf-9faabb182861?source=collection_link" + }, + "item": [ + { + "name": "Webhooks", + "item": [ + { + "name": "Create a webhook", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"display_name\": \"Order Notifications\",\n \"endpoint_url\": \"https://example.com/webhooks/orders\",\n \"event_types\": [\n \"messages\",\n \"templates\"\n ],\n \"retry_count\": 3,\n \"timeout_seconds\": 30\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/webhooks", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks" + ] + }, + "description": "Creates a new webhook endpoint for the authenticated customer." + }, + "response": [] + }, + { + "name": "Get webhooks list", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/webhooks?page=1&page_size=1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "1" + }, + { + "key": "search", + "value": "string", + "disabled": true + }, + { + "key": "is_active", + "value": "", + "disabled": true + } + ] + }, + "description": "Retrieves a paginated list of webhooks for the authenticated customer." + }, + "response": [] + }, + { + "name": "Delete a webhook", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Deletes a webhook for the authenticated customer." + }, + "response": [] + }, + { + "name": "Get a webhook", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Retrieves a single webhook by ID for the authenticated customer." + }, + "response": [] + }, + { + "name": "Update a webhook", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"display_name\": \"Updated Order Notifications\",\n \"endpoint_url\": \"https://example.com/webhooks/orders-v2\",\n \"event_types\": [\n \"messages\",\n \"templates\"\n ],\n \"retry_count\": 5,\n \"timeout_seconds\": 60\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Updates an existing webhook for the authenticated customer." + }, + "response": [] + }, + { + "name": "Get webhook events", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id/events?page=1&page_size=1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id", + "events" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "1" + }, + { + "key": "search", + "value": "string", + "disabled": true + } + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Retrieves a paginated list of delivery events for the specified webhook." + }, + "response": [] + }, + { + "name": "Get available webhook event types", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/webhooks/event-types", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + "event-types" + ] + }, + "description": "Retrieves all available webhook event types that can be subscribed to." + }, + "response": [] + }, + { + "name": "Rotate webhook signing secret", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id/rotate-secret", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id", + "rotate-secret" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Generates a new signing secret for the specified webhook. The old secret is immediately invalidated." + }, + "response": [] + }, + { + "name": "Test a webhook", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"event_type\": \"message.sent\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id/test", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id", + "test" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Sends a test event to the specified webhook endpoint to verify connectivity." + }, + "response": [] + }, + { + "name": "Toggle webhook status", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"is_active\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/webhooks/:id/toggle-status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "webhooks", + ":id", + "toggle-status" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Activates or deactivates a webhook for the authenticated customer." + }, + "response": [] + } + ], + "description": "Manage webhook endpoints that receive real-time event notifications for messages and templates.\n\nEvents are delivered as `POST` requests to your endpoint with a nested `{ field, timestamp, payload }` structure. Verify the `X-Webhook-Signature` header on every incoming request and use `X-Webhook-ID` for idempotency.\n\n📖 [Webhooks — Getting Started](https://docs.sent.dm/start/webhooks/getting-started)\n📖 [Event Types](https://docs.sent.dm/start/webhooks/event-types)\n📖 [Local Development](https://docs.sent.dm/start/webhooks/local-development)\n📖 [Handling Retries](https://docs.sent.dm/start/webhooks/handling-retries)\n📖 [Signature Verification](https://docs.sent.dm/start/webhooks/signature-verification)\n📖 [Production Checklist](https://docs.sent.dm/start/webhooks/production-checklist)" + }, + { + "name": "Users", + "item": [ + { + "name": "Get user by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/users/:userId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "users", + ":userId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + } + ] + }, + "description": "Retrieves detailed information about a specific user in an organization or profile. Requires developer role or higher." + }, + "response": [] + }, + { + "name": "Remove user", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/users/:userId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "users", + ":userId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + } + ] + }, + "description": "Removes a user's access to an organization or profile. Requires admin role. You cannot remove yourself or remove the last admin." + }, + "response": [] + }, + { + "name": "Update user role", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"role\": \"billing\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/users/:userId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "users", + ":userId" + ], + "variable": [ + { + "key": "userId", + "value": "{{userId}}" + } + ] + }, + "description": "Updates a user's role in the organization or profile. Requires admin role. You cannot change your own role or demote the last admin." + }, + "response": [] + }, + { + "name": "List users", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/users", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "users" + ] + }, + "description": "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." + }, + "response": [] + }, + { + "name": "Invite a user", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"email\": \"newuser@example.com\",\n \"name\": \"New User\",\n \"role\": \"developer\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/users", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "users" + ] + }, + "description": "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." + }, + "response": [] + } + ], + "description": "Manage user accounts and roles within your organization. Invite new users, update roles (`admin`, `member`, `billing`), or remove access.\n\n📖 [Dashboard Walkthrough](https://docs.sent.dm/start/quickstart/dashboard-walkthrough)" + }, + { + "name": "Templates", + "item": [ + { + "name": "Create a new template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"category\": \"MARKETING\",\n \"language\": \"en_US\",\n \"definition\": {\n \"header\": null,\n \"body\": {\n \"multiChannel\": {\n \"type\": null,\n \"template\": \"Hello {{0:variable}}! Welcome to {{1:variable}}.\",\n \"variables\": [\n {\n \"id\": 0,\n \"name\": \"name\",\n \"type\": \"variable\",\n \"props\": {\n \"variableType\": \"text\",\n \"sample\": \"John\",\n \"regex\": null,\n \"url\": null,\n \"shortUrl\": null,\n \"alt\": null,\n \"mediaType\": null\n }\n },\n {\n \"id\": 1,\n \"name\": \"company\",\n \"type\": \"variable\",\n \"props\": {\n \"variableType\": \"text\",\n \"sample\": \"SentDM\",\n \"regex\": null,\n \"url\": null,\n \"shortUrl\": null,\n \"alt\": null,\n \"mediaType\": null\n }\n }\n ]\n },\n \"sms\": null,\n \"whatsapp\": null\n },\n \"footer\": null,\n \"buttons\": null,\n \"definitionVersion\": \"1.0\",\n \"authenticationConfig\": null\n },\n \"submit_for_review\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/templates", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "templates" + ] + }, + "description": "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." + }, + "response": [] + }, + { + "name": "Get templates list", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/templates?page=1&page_size=1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "templates" + ], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (1-indexed)" + }, + { + "key": "page_size", + "value": "1", + "description": "Number of items per page" + }, + { + "key": "search", + "value": "string", + "description": "Optional search term for filtering templates", + "disabled": true + }, + { + "key": "status", + "value": "string", + "description": "Optional status filter: APPROVED, PENDING, REJECTED", + "disabled": true + }, + { + "key": "category", + "value": "string", + "description": "Optional category filter: MARKETING, UTILITY, AUTHENTICATION", + "disabled": true + }, + { + "key": "is_welcome_playground", + "value": "", + "description": "Optional filter by welcome playground flag", + "disabled": true + } + ] + }, + "description": "Retrieves a paginated list of message templates for the authenticated customer. Supports filtering by status, category, and search term." + }, + "response": [] + }, + { + "name": "Delete a template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"delete_from_meta\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/templates/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "templates", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Deletes a template by ID. Optionally, you can also delete the template from WhatsApp/Meta by setting delete_from_meta=true." + }, + "response": [] + }, + { + "name": "Get template by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/templates/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "templates", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Retrieves a specific template by its ID. Returns template details including name, category, language, status, and definition." + }, + "response": [] + }, + { + "name": "Update a template", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"name\": \"Updated Welcome Message\",\n \"category\": \"MARKETING\",\n \"definition\": {\n \"header\": {\n \"type\": \"string\",\n \"template\": \"string\",\n \"variables\": [\n {\n \"id\": 1,\n \"name\": \"string\",\n \"type\": \"string\",\n \"props\": {}\n }\n ]\n },\n \"footer\": {\n \"type\": \"string\",\n \"template\": \"string\",\n \"variables\": [\n {\n \"id\": 1,\n \"name\": \"string\",\n \"type\": \"string\",\n \"props\": {}\n }\n ]\n },\n \"buttons\": [\n {\n \"id\": 1,\n \"type\": \"string\"\n }\n ],\n \"definitionVersion\": \"string\",\n \"authenticationConfig\": {\n \"addSecurityRecommendation\": false,\n \"codeExpirationMinutes\": 1\n }\n },\n \"submit_for_review\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/templates/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "templates", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Updates an existing template's name, category, language, definition, or submits it for review." + }, + "response": [] + } + ], + "description": "Create and manage reusable message templates for SMS and WhatsApp. Templates go through a lifecycle: `DRAFT → PENDING → APPROVED / REJECTED`. WhatsApp templates require Meta approval before use on that channel; SMS delivery is available immediately.\n\n📖 [Working with Templates](https://docs.sent.dm/start/guides/working-with-templates)\n📖 [First Template Quickstart](https://docs.sent.dm/start/quickstart/first-template)" + }, + { + "name": "Profiles", + "item": [ + { + "name": "Complete profile setup", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"webHookUrl\": \"https://your-app.com/webhook/profile-complete\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId/complete", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId", + "complete" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + } + ] + }, + "description": "Final step in profile compliance workflow. Validates all prerequisites (general data, brand, campaigns), connects profile to Telnyx/WhatsApp, and sets status based on configuration. The process runs in the background and calls the provided webhook URL when finished.\n\n Prerequisites:\n - Profile must be completed\n - If inheritTcrBrand=false: Profile must have existing brand\n - If inheritTcrBrand=true: Parent must have existing brand\n - If TCR application: Must have at least one campaign (own or inherited)\n - If inheritTcrCampaign=false: Profile should have campaigns\n - If inheritTcrCampaign=true: Parent must have campaigns\n\n Status Logic:\n - If both SMS and WhatsApp channels are missing → SUBMITTED\n - If TCR application and not inheriting brand/campaigns → SUBMITTED\n - If non-TCR with destination country (IsMain=true) → SUBMITTED\n - Otherwise → COMPLETED" + }, + "response": [] + }, + { + "name": "Create a new profile", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"name\": \"Sales Team\",\n \"icon\": \"https://example.com/sales-icon.png\",\n \"description\": \"Sales department sender profile\",\n \"short_name\": \"SALES\",\n \"allow_contact_sharing\": true,\n \"allow_template_sharing\": false,\n \"inherit_contacts\": true,\n \"inherit_templates\": true,\n \"inherit_tcr_brand\": false,\n \"inherit_tcr_campaign\": false,\n \"billing_model\": \"profile\",\n \"billing_contact\": {\n \"name\": \"string\",\n \"email\": \"string\",\n \"phone\": \"string\",\n \"address\": \"string\"\n },\n \"whatsapp_business_account\": {\n \"waba_id\": \"string\",\n \"phone_number_id\": \"string\",\n \"access_token\": \"string\"\n },\n \"brand\": {\n \"business\": {\n \"legalName\": \"string\",\n \"taxId\": \"string\",\n \"taxIdType\": \"string\",\n \"street\": \"string\",\n \"city\": \"string\",\n \"state\": \"string\",\n \"postalCode\": \"string\",\n \"country\": \"string\",\n \"url\": \"https://example.com/webhook\",\n \"countryOfRegistration\": \"string\"\n }\n },\n \"payment_details\": {\n \"card_number\": \"string\",\n \"expiry\": \"string\",\n \"cvc\": \"string\",\n \"zip_code\": \"string\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles" + ] + }, + "description": "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.\n\n## WhatsApp Business Account\n\nEvery profile must be linked to a WhatsApp Business Account. There are two ways to do this:\n\n**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.\n\n**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.\n\nIf the `whatsapp_business_account` field is omitted and the organization has no WhatsApp Business Account configured, the request will be rejected with HTTP 422.\n\n## Brand\n\nInclude the optional `brand` field to create the brand for this profile at the same time. Cannot be used when `inherit_tcr_brand` is `true`.\n\n## Payment Details\n\nWhen `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." + }, + "response": [] + }, + { + "name": "List profiles in organization", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/profiles", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles" + ] + }, + "description": "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." + }, + "response": [] + }, + { + "name": "Delete a profile", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + } + ] + }, + "description": "Soft deletes a sender profile. The profile will be marked as deleted but data is retained. Requires admin role in the organization." + }, + "response": [] + }, + { + "name": "Get profile by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + } + ] + }, + "description": "Retrieves detailed information about a specific sender profile within an organization, including brand and KYC information if a brand has been configured." + }, + "response": [] + }, + { + "name": "Update profile settings", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"name\": \"Sales Team - Updated\",\n \"description\": \"Updated sales department sender profile\",\n \"short_name\": \"SALES\",\n \"allow_contact_sharing\": true,\n \"billing_model\": \"organization\",\n \"billing_contact\": {\n \"name\": \"string\",\n \"email\": \"string\",\n \"phone\": \"string\",\n \"address\": \"string\"\n },\n \"brand\": {\n \"business\": {\n \"legalName\": \"string\",\n \"taxId\": \"string\",\n \"taxIdType\": \"string\",\n \"street\": \"string\",\n \"city\": \"string\",\n \"state\": \"string\",\n \"postalCode\": \"string\",\n \"country\": \"string\",\n \"url\": \"https://example.com/webhook\",\n \"countryOfRegistration\": \"string\"\n }\n },\n \"payment_details\": {\n \"card_number\": \"string\",\n \"expiry\": \"string\",\n \"cvc\": \"string\",\n \"zip_code\": \"string\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + } + ] + }, + "description": "Updates a profile's configuration and settings. Requires admin role in the organization. Only provided fields will be updated (partial update).\n\n## Brand Management\n\nInclude 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.\n\n## Payment Details\n\nWhen `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." + }, + "response": [] + }, + { + "name": "Create a campaign for a profile's brand", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"campaign\": {\n \"name\": \"Customer Notifications\",\n \"description\": \"Appointment reminders and account notifications\",\n \"type\": \"App\",\n \"useCases\": [\n {\n \"messagingUseCaseUs\": \"ACCOUNT_NOTIFICATION\",\n \"sampleMessages\": [\n \"Hi {name}, your appointment is confirmed for {date} at {time}.\",\n \"Your order #{order_id} has been shipped. Track at {url}\"\n ]\n }\n ],\n \"messageFlow\": \"User signs up on website and opts in to receive SMS notifications\",\n \"privacyPolicyLink\": \"https://acmecorp.com/privacy\",\n \"termsAndConditionsLink\": \"https://acmecorp.com/terms\",\n \"optinMessage\": \"You have opted in to Acme Corp notifications. Reply STOP to opt out.\",\n \"optoutMessage\": \"You have been unsubscribed. Reply START to opt back in.\",\n \"helpMessage\": \"Reply STOP to unsubscribe or contact support@acmecorp.com\",\n \"optinKeywords\": \"YES, START, SUBSCRIBE\",\n \"optoutKeywords\": \"STOP, UNSUBSCRIBE, END\",\n \"helpKeywords\": \"HELP, INFO, SUPPORT\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId/campaigns", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId", + "campaigns" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + } + ] + }, + "description": "Creates a new campaign scoped under the brand of the specified profile. Each campaign must include at least one use case with sample messages." + }, + "response": [] + }, + { + "name": "Get campaigns for a profile's brand", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId/campaigns", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId", + "campaigns" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + } + ] + }, + "description": "Retrieves all campaigns linked to the profile's brand, including use cases and sample messages. Returns inherited campaigns if inherit_tcr_campaign=true." + }, + "response": [] + }, + { + "name": "Delete a campaign", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId/campaigns/:campaignId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId", + "campaigns", + ":campaignId" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + }, + { + "key": "campaignId", + "value": "{{campaignId}}" + } + ] + }, + "description": "Deletes a campaign by ID from the brand of the specified profile. The profile must belong to the authenticated organization." + }, + "response": [] + }, + { + "name": "Update a campaign", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"campaign\": {\n \"name\": \"Customer Notifications Updated\",\n \"description\": \"Updated appointment reminders and account notifications\",\n \"type\": \"App\",\n \"useCases\": [\n {\n \"messagingUseCaseUs\": \"ACCOUNT_NOTIFICATION\",\n \"sampleMessages\": [\n \"Hi {name}, your appointment is confirmed for {date} at {time}.\",\n \"Your order #{order_id} has been shipped. Track at {url}\"\n ]\n }\n ],\n \"messageFlow\": \"User signs up on website and opts in to receive SMS notifications\",\n \"privacyPolicyLink\": null,\n \"termsAndConditionsLink\": null,\n \"optinMessage\": null,\n \"optoutMessage\": null,\n \"helpMessage\": null,\n \"optinKeywords\": null,\n \"optoutKeywords\": null,\n \"helpKeywords\": null\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/profiles/:profileId/campaigns/:campaignId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "profiles", + ":profileId", + "campaigns", + ":campaignId" + ], + "variable": [ + { + "key": "profileId", + "value": "{{profileId}}" + }, + { + "key": "campaignId", + "value": "{{campaignId}}" + } + ] + }, + "description": "Updates an existing campaign under the brand of the specified profile. Cannot update campaigns that have already been submitted to TCR." + }, + "response": [] + } + ], + "description": "Profiles enable multi-tenant architectures — each profile is an isolated messaging identity with its own contacts, templates, and webhooks. Organization API keys can act on behalf of a profile by passing the `x-profile-id` header.\n\nProfiles also hold 10DLC brand and campaign registrations required for US SMS compliance.\n\n📖 [Multi-Tenant Architectures](https://docs.sent.dm/start/advanced/multi-tenant-architectures)\n📖 [Compliance & Regulations](https://docs.sent.dm/start/advanced/compliance-regulations)" + }, + { + "name": "Numbers", + "item": [ + { + "name": "Get phone number details", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/numbers/lookup/:phoneNumber", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "numbers", + "lookup", + ":phoneNumber" + ], + "variable": [ + { + "key": "phoneNumber", + "value": "{{phoneNumber}}" + } + ] + }, + "description": "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." + }, + "response": [] + } + ], + "description": "Look up a phone number to check its validity and available channels (SMS / WhatsApp) before sending. Useful for pre-validating recipients without creating a contact.\n\n📖 [Managing Contacts](https://docs.sent.dm/start/guides/managing-contacts)" + }, + { + "name": "Messages", + "item": [ + { + "name": "Get message activities", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/messages/:id/activities", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "messages", + ":id", + "activities" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Retrieves the activity log for a specific message. Activities track the message lifecycle including acceptance, processing, sending, delivery, and any errors." + }, + "response": [] + }, + { + "name": "Get message status", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/messages/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "messages", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Retrieves the current status and details of a message by ID. Includes delivery status, timestamps, and error information if applicable." + }, + "response": [] + }, + { + "name": "Send a message", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"to\": [\n \"+14155551234\",\n \"+14155555678\"\n ],\n \"channel\": [\n \"sms\",\n \"whatsapp\"\n ],\n \"template\": {\n \"id\": \"7ba7b820-9dad-11d1-80b4-00c04fd430c8\",\n \"name\": \"order_confirmation\",\n \"parameters\": {\n \"name\": \"John Doe\",\n \"order_id\": \"12345\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "messages" + ] + }, + "description": "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." + }, + "response": [] + } + ], + "description": "Send SMS and WhatsApp messages using pre-approved templates. A single request can target up to **1,000 recipients**; separate messages are created per (recipient, channel) pair — there is no implicit fallback when multiple channels are specified.\n\nPoll `/v3/messages/{id}/activities` or use webhooks to track delivery status (`QUEUED → SENT → DELIVERED → READ / FAILED`).\n\n📖 [Sending Messages](https://docs.sent.dm/start/guides/sending-messages)\n📖 [Message Responses](https://docs.sent.dm/start/guides/message-responses)\n📖 [Message Status Tracking](https://docs.sent.dm/start/guides/message-status-tracking)\n📖 [Batch Operations](https://docs.sent.dm/start/guides/batch-operations)" + }, + { + "name": "Contacts", + "item": [ + { + "name": "Create a contact", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"phone_number\": \"+1234567890\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/contacts", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "contacts" + ] + }, + "description": "Creates a new contact by phone number and associates it with the authenticated customer." + }, + "response": [] + }, + { + "name": "Get contacts list", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/contacts?page=1&page_size=1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "contacts" + ], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (1-indexed)" + }, + { + "key": "page_size", + "value": "1", + "description": "Number of items per page" + }, + { + "key": "search", + "value": "string", + "description": "Optional search term for filtering contacts", + "disabled": true + }, + { + "key": "channel", + "value": "string", + "description": "Optional channel filter (sms, whatsapp)", + "disabled": true + }, + { + "key": "phone", + "value": "string", + "description": "Optional phone number filter (alternative to list view)", + "disabled": true + } + ] + }, + "description": "Retrieves a paginated list of contacts for the authenticated customer. Supports filtering by search term, channel, or phone number." + }, + "response": [] + }, + { + "name": "Delete a contact", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/contacts/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "contacts", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Dissociates a contact from the authenticated customer. Inherited contacts cannot be deleted." + }, + "response": [] + }, + { + "name": "Get contact by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/contacts/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "contacts", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Retrieves a specific contact by their unique identifier. Returns detailed contact information including phone formats, available channels, and opt-out status." + }, + "response": [] + }, + { + "name": "Update a contact", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "Idempotency-Key", + "value": "string", + "description": "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.", + "disabled": true + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sandbox\": false,\n \"default_channel\": \"whatsapp\",\n \"opt_out\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/v3/contacts/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "contacts", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "{{id}}" + } + ] + }, + "description": "Updates a contact's default channel and/or opt-out status. Inherited contacts cannot be updated." + }, + "response": [] + } + ], + "description": "Contacts are validated, channel-aware communication endpoints. Creating a contact triggers real-time validation across SMS and WhatsApp, caching channel availability for optimized future routing.\n\nContacts are created lazily on first message send, or explicitly via `POST /v3/contacts`.\n\n📖 [Managing Contacts](https://docs.sent.dm/start/guides/managing-contacts)" + }, + { + "name": "Accounts", + "item": [ + { + "name": "Get authenticated account", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "key": "x-profile-id", + "value": "{{$guid}}", + "description": "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.", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/v3/me", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v3", + "me" + ] + }, + "description": "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.\n\n**Account types:**\n- `organization` — Has child profiles. The `profiles` array is populated.\n- `user` — Standalone account with no profiles.\n- `profile` — Child of an organization. Includes `organization_id`, `short_name`, `status`, and `settings`.\n\n**Channels:**\nThe `channels` object always includes `sms`, `whatsapp`, and `rcs`. Each channel has a `configured` boolean. Configured channels expose additional details such as `phone_number`." + }, + "response": [] + } + ], + "description": "Retrieve details about the authenticated account — balance, plan, and configuration.\n\n📖 [Account Setup](https://docs.sent.dm/start/quickstart/account-setup)" + } + ], + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "key", + "value": "x-api-key", + "type": "string" + }, + { + "key": "value", + "value": "{{apiKey}}", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "https://api.sent.dm" + }, + { + "key": "apiKey", + "value": "", + "description": "Your Sent DM API key" + } + ] +} diff --git a/jvagent/action/sentdm_broadcast/endpoints.py b/jvagent/action/sentdm_broadcast/endpoints.py new file mode 100644 index 00000000..2579f048 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/endpoints.py @@ -0,0 +1,551 @@ +"""HTTP endpoints for SentDMBroadcastAction. + +Admin routes are scoped by ``action_id`` and require an authenticated admin +session. The public webhook route is registered with SentDM at startup +(``reconcile_webhook_endpoint``); it is protected by an ``api_key`` query +parameter (jvspatial webhook middleware) and verifies the SentDM +``X-Webhook-Signature`` per Sent's scheme (``v1,{base64}`` using +``{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`` and a ``whsec_`` signing +secret), with a legacy fallback for older hex digests over the raw body only. +""" + +import base64 +import binascii +import hashlib +import hmac +import json +import logging +import time +from collections import OrderedDict +from threading import Lock +from typing import Any, Dict, List, Optional, Tuple, Union + +import httpx +from fastapi import HTTPException, Request +from jvspatial.api import endpoint +from jvspatial.api.exceptions import ResourceNotFoundError +from pydantic import Field + +from .sentdm_broadcast_action import ( + _DEFAULT_WEBHOOK_EVENT_FILTERS, + SentDMBroadcastAction, +) + +logger = logging.getLogger(__name__) + +_WEBHOOK_ID_CACHE_MAX = 1024 +_seen_webhook_ids: "OrderedDict[str, None]" = OrderedDict() +_seen_webhook_ids_lock = Lock() + + +def _remember_webhook_id(webhook_id: str) -> bool: + """Return True if ``webhook_id`` is new (and remember it), False if duplicate.""" + key = (webhook_id or "").strip() + if not key: + return True + with _seen_webhook_ids_lock: + if key in _seen_webhook_ids: + _seen_webhook_ids.move_to_end(key) + return False + _seen_webhook_ids[key] = None + while len(_seen_webhook_ids) > _WEBHOOK_ID_CACHE_MAX: + _seen_webhook_ids.popitem(last=False) + return True + + +def _sentdm_decode_signing_secret(secret: str) -> Optional[bytes]: + """Decode Sent signing secret (``whsec_`` + base64) to raw HMAC key bytes.""" + s = (secret or "").strip() + if not s: + return None + material = s[6:] if s.startswith("whsec_") else s + pad = (-len(material)) % 4 + padded = material + ("=" * pad) + try: + out = base64.b64decode(padded) + except (binascii.Error, ValueError): + out = b"" + if out: + return out + try: + out = base64.urlsafe_b64decode(padded) + except (binascii.Error, ValueError): + out = b"" + if out: + return out + if s.startswith("whsec_"): + return None + return s.encode("utf-8") + + +def _sentdm_timestamp_acceptable(timestamp_header: str, *, max_skew: int = 600) -> bool: + """Reject wildly stale ``x-webhook-timestamp`` values (replay mitigation).""" + raw = (timestamp_header or "").strip() + if not raw: + return True + try: + ts = int(raw, 10) + except ValueError: + return False + return abs(int(time.time()) - ts) <= max_skew + + +def _b64decode_signature_blob(sig_b64: str) -> Optional[bytes]: + s = (sig_b64 or "").strip() + if not s: + return None + pad = (-len(s)) % 4 + padded = s + ("=" * pad) + try: + return base64.b64decode(padded) + except (binascii.Error, ValueError): + pass + try: + return base64.urlsafe_b64decode(padded) + except (binascii.Error, ValueError): + return None + + +def _verify_sentdm_signature_v1( + secret: str, + raw_body: bytes, + sig_b64: str, + webhook_id: str, + timestamp: str, +) -> bool: + """Sent documented scheme: HMAC-SHA256(key, f'{id}.{ts}.{body}') → base64, header ``v1,…``.""" + key = _sentdm_decode_signing_secret(secret) + if key is None: + return False + wid = (webhook_id or "").strip() + ts = (timestamp or "").strip() + if not wid or not ts: + return False + if not _sentdm_timestamp_acceptable(ts): + return False + try: + body_text = raw_body.decode("utf-8") + except UnicodeDecodeError: + body_text = raw_body.decode("utf-8", errors="surrogateescape") + signed = f"{wid}.{ts}.{body_text}" + digest = hmac.new(key, signed.encode("utf-8"), hashlib.sha256).digest() + provided = _b64decode_signature_blob(sig_b64) + if not provided: + return False + return hmac.compare_digest(digest, provided) + + +def _verify_sentdm_signature_legacy( + secret: str, raw_body: bytes, signature_header: str +) -> bool: + """Legacy: hex HMAC-SHA256(secret utf-8, raw_body) or ``sha256=`` hex prefix.""" + sig = str(signature_header).strip() + if sig.startswith("sha256="): + sig = sig[len("sha256=") :].strip() + if len(sig) != 64 or any(c not in "0123456789abcdefABCDEF" for c in sig): + return False + expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(sig.lower(), expected.lower()) + + +def _verify_sentdm_signature( + secret: str, + raw_body: bytes, + signature_header: Optional[str], + *, + webhook_id: str, + timestamp: str, +) -> bool: + """Verify ``X-Webhook-Signature`` (Sent ``v1,`` base64, else legacy hex).""" + if not secret or not signature_header: + return False + sh = str(signature_header).strip() + if sh.lower().startswith("v1,"): + return _verify_sentdm_signature_v1( + secret, raw_body, sh[3:].strip(), webhook_id, timestamp + ) + return _verify_sentdm_signature_legacy(secret, raw_body, sh) + + +async def _get_sentdm_action(action_id: str) -> SentDMBroadcastAction: + action = await SentDMBroadcastAction.get(action_id) + if not action or not isinstance(action, SentDMBroadcastAction): + raise ResourceNotFoundError(f"SentDM broadcast action not found: {action_id}") + return action + + +def _httpx_error_to_http(exc: httpx.HTTPStatusError) -> HTTPException: + """Convert an upstream SentDM HTTPStatusError into a FastAPI HTTPException.""" + try: + body: Any = exc.response.json() + except ValueError: + body = exc.response.text + return HTTPException( + status_code=exc.response.status_code, + detail={"upstream": body, "message": str(exc)}, + ) + + +def _extract_message_id_from_event(event_payload: Any) -> str: + """Pull a SentDM message id out of a webhook event payload. + + SentDM's webhook event shape isn't fully nailed down in the public docs, + so we probe a handful of likely paths and return the first non-empty + string. Returns ``""`` when nothing was found. + """ + if not isinstance(event_payload, dict): + return "" + direct_keys = ("id", "message_id", "messageId", "sentdm_message_id") + for key in direct_keys: + value = event_payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for nested_key in ("message", "data"): + nested = event_payload.get(nested_key) + if isinstance(nested, dict): + for key in direct_keys: + value = nested.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _resolve_sentdm_webhook_message_id(payload: Any, fold: Dict[str, Any]) -> str: + """Resolve SentDM message id from normalized ``fold`` and optional raw root.""" + mid = _extract_message_id_from_event(fold) + if mid: + return mid + if isinstance(payload, dict): + mid = _extract_message_id_from_event(payload) + if mid: + return mid + rb = payload.get("responseBody") + if isinstance(rb, str) and rb.strip(): + try: + parsed = json.loads(rb) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + mid = _extract_message_id_from_event(parsed) + if mid: + return mid + return "" + + +def _normalize_sentdm_webhook_envelope(payload: Any) -> Tuple[str, Dict[str, Any]]: + """Normalize Sent webhook JSON to ``(field, fold)`` for derive + record update. + + Handles the documented envelope ``{field, sub_type, timestamp, payload}`` and a + dashboard-style wrapper ``{eventType, eventData: {...}}``. + """ + if not isinstance(payload, dict): + return "", {} + root = payload + env: Dict[str, Any] = root + nested = root.get("eventData") + if isinstance(nested, dict) and ( + isinstance(nested.get("payload"), dict) or nested.get("field") is not None + ): + env = nested + field = str(env.get("field") or root.get("field") or "") + sub_raw = env.get("sub_type") + if not (isinstance(sub_raw, str) and sub_raw.strip()): + sub_raw = root.get("eventType") or root.get("event_type") + sub_type = str(sub_raw).strip() if isinstance(sub_raw, str) else "" + inner = env.get("payload") + if not isinstance(inner, dict): + inner = {} + fold: Dict[str, Any] = dict(inner) + if sub_type: + fold["sub_type"] = sub_type + fold["event"] = sub_type + if ( + "message_id" not in fold + and isinstance(fold.get("id"), str) + and fold["id"].strip() + ): + fold["message_id"] = fold["id"].strip() + return field, fold + + +@endpoint( + "/actions/{action_id}/broadcast", + methods=["POST"], + auth=True, + roles=["admin"], + tags=["SentDM Broadcast"], + summary="Send a SentDM broadcast (POST /v3/messages)", +) +async def sentdm_broadcast( + action_id: str, + to: Union[str, List[str]] = Field( + ..., + description="Recipient E.164 number(s). Pass a string or an array of strings.", + examples=["5920000000"], + ), + template: Optional[Dict[str, Any]] = Field( + None, + description=( + "Template selector: ``id`` and/or ``name``. Optional nested " + "``parameters`` are merged under top-level ``parameters``." + ), + examples=[{"id": "f70c78f8-4be0-49eb-88e2-cd7aa9a7cef9"}], + ), + channels: Optional[List[str]] = Field( + None, + description=( + "Channels to try in order, e.g. ``sms``, ``whatsapp``, ``rcs``. " + "Defaults to the action's ``default_channels``." + ), + ), + parameters: Optional[Dict[str, Any]] = Field( + None, + description=( + "Template variables (Sent placeholder names → values), merged on top " + "of any ``template.parameters``." + ), + examples=[{"var_1": "123456"}], + ), + sandbox: Optional[bool] = Field( + None, + description="When true, Sent validates the payload without delivering to carriers.", + examples=[True], + ), + idempotency_key: Optional[str] = Field( + default=None, + description="Forwarded as the ``idempotency-key`` header on the SentDM request.", + ), + profile_id: Optional[str] = Field( + default=None, + description="Forwarded as ``x-profile-id`` when using a Sent child profile.", + ), +) -> Dict[str, Any]: + """Send a broadcast via the configured SentDM action. + + **Example body** + + :: + + { + "to": "5920000000", + "template": {"id": "f70c78f8-4be0-49eb-88e2-cd7aa9a7cef9"}, + "parameters": {"var_1": "123456"}, + "sandbox": true + } + + **Fields** + + - ``to`` (required): one E.164 number or a list of numbers. + - ``template`` (optional): ``{"id"?, "name"?, "parameters"?}``. Falls back to + ``default_template_id`` / ``default_template_name`` on the action. + - ``channels`` (optional): defaults to ``default_channels``. + - ``parameters`` (optional): merged on top of ``template.parameters``. + - ``sandbox`` (optional): per-call override. + - ``idempotency_key`` (optional): forwarded as ``idempotency-key`` header. + - ``profile_id`` (optional): forwarded as ``x-profile-id`` header. + """ + action = await _get_sentdm_action(action_id) + if not action.is_configured(): + raise HTTPException( + status_code=400, + detail={ + "message": "SentDM action is not configured", + "issues": action._config_issues(), + }, + ) + + if isinstance(to, str): + recipients: List[str] = [to] + elif isinstance(to, list): + recipients = [str(t) for t in to if t is not None and str(t).strip()] + else: + raise HTTPException( + status_code=400, detail="'to' must be a string or list of strings" + ) + if not recipients: + raise HTTPException( + status_code=400, detail="'to' must contain at least one recipient" + ) + + try: + return await action.send_broadcast( + recipients, + template=template, + channels=channels, + parameters=parameters, + sandbox=sandbox, + idempotency_key=idempotency_key, + profile_id=profile_id, + ) + except httpx.HTTPStatusError as exc: + raise _httpx_error_to_http(exc) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@endpoint( + "/actions/{action_id}/webhook/register", + methods=["POST"], + auth=True, + roles=["admin"], + tags=["SentDM Broadcast"], + summary="Force a SentDM webhook reconcile", +) +async def sentdm_register_webhook(action_id: str) -> Dict[str, Any]: + """Manually trigger :py:meth:`SentDMBroadcastAction.reconcile_webhook_endpoint`.""" + action = await _get_sentdm_action(action_id) + try: + return await action.reconcile_webhook_endpoint() + except httpx.HTTPStatusError as exc: + raise _httpx_error_to_http(exc) + + +@endpoint( + "/actions/{action_id}/webhook", + methods=["GET"], + auth=True, + roles=["admin"], + tags=["SentDM Broadcast"], + summary="Show the currently registered SentDM webhook URL", +) +async def sentdm_get_webhook(action_id: str) -> Dict[str, Any]: + """Return the persisted webhook URL + SentDM webhook id (read-only). + + Does not contact SentDM. To force a reconcile, POST + ``/actions/{action_id}/webhook/register``. + """ + action = await _get_sentdm_action(action_id) + eff = ( + {k: list(v) for k, v in _DEFAULT_WEBHOOK_EVENT_FILTERS.items()} + if action.webhook_event_filters is None + else dict(action.webhook_event_filters) + ) + return { + "configured": action.is_configured(), + "webhook_url": action.webhook_url, + "sentdm_webhook_id": action.sentdm_webhook_id, + "has_signing_secret": bool((action.sentdm_webhook_secret or "").strip()), + "event_types": list(action.webhook_event_types or []), + "webhook_event_filters": action.webhook_event_filters, + "event_filters_effective": eff, + "display_name": action.webhook_display_name, + } + + +@endpoint( + "/webhook/{action_id}", + methods=["POST"], + webhook=True, + auth=False, + webhook_auth="api_key", + tags=["SentDM Broadcast"], + summary="Inbound SentDM webhook (delivery / template events)", +) +async def sentdm_webhook_receive(request: Request, action_id: str) -> Dict[str, Any]: + """Receive a signed event from SentDM. + + Performs: + 1. ``X-Webhook-Signature`` verification (Sent ``v1,{base64}`` scheme, or legacy + hex digest over the raw body). + 2. ``X-Webhook-ID`` de-duplication via an in-memory LRU. + 3. Logging of the event for later inspection. Downstream dispatch hooks + can be added without changing the wire contract. + """ + logger.info( + "SentDM webhook route reached (jvspatial webhook api_key auth passed): " + "action_id=%s", + action_id, + ) + action = await _get_sentdm_action(action_id) + secret = (action.sentdm_webhook_secret or "").strip() + if not secret: + logger.warning( + "SentDM webhook for action %s arrived but no signing secret is on record", + action_id, + ) + raise HTTPException( + status_code=500, + detail="SentDM webhook signing secret is not configured on this action", + ) + + raw_body: bytes = getattr(request.state, "raw_body", b"") or b"" + if not raw_body: + raw_body = await request.body() + + webhook_id = (request.headers.get("x-webhook-id") or "").strip() + timestamp_hdr = (request.headers.get("x-webhook-timestamp") or "").strip() + signature = request.headers.get("x-webhook-signature") + if not _verify_sentdm_signature( + secret, + raw_body, + signature, + webhook_id=webhook_id, + timestamp=timestamp_hdr, + ): + sig_desc = "absent" + if signature: + s = str(signature).strip() + sig_desc = ( + f"len={len(s)} v1_prefix={s.lower().startswith('v1,')} " + f"sha256_prefix={s.lower().startswith('sha256=')}" + ) + logger.warning( + "SentDM webhook HMAC verification failed: action_id=%s raw_body_bytes=%s " + "signature_header=%s x_webhook_id_len=%s x_webhook_timestamp=%r", + action_id, + len(raw_body), + sig_desc, + len(webhook_id), + timestamp_hdr[:32] if timestamp_hdr else "", + ) + raise HTTPException(status_code=401, detail="Invalid X-Webhook-Signature") + + if webhook_id and not _remember_webhook_id(webhook_id): + return {"status": "duplicate", "webhook_id": webhook_id} + + payload: Any = getattr(request.state, "parsed_payload", None) + if payload is None: + try: + payload = json.loads(raw_body.decode("utf-8")) if raw_body else {} + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + logger.warning("SentDM webhook JSON parse error: %s", exc) + raise HTTPException(status_code=400, detail="Invalid JSON body") + + field, fold = _normalize_sentdm_webhook_envelope(payload) + sentdm_message_id = _resolve_sentdm_webhook_message_id(payload, fold) + + logger.info( + "SentDM webhook received: action=%s webhook_id=%s field=%s message_id=%s", + action_id, + webhook_id or "(none)", + field or "(unknown)", + sentdm_message_id or "(none)", + ) + logger.debug("SentDM webhook fold (action=%s): %r", action_id, fold) + + record_id: Optional[str] = None + record_status: Optional[str] = None + if sentdm_message_id: + try: + updated = await action.apply_webhook_with_upsert( + field, fold, sentdm_message_id + ) + if updated is not None: + record_id = updated.id + record_status = updated.status + except Exception as exc: # pragma: no cover - DB hiccup, best effort + logger.warning( + "SentDM webhook (action=%s) record upsert/update failed for %s: %s", + action_id, + sentdm_message_id, + exc, + ) + + return { + "status": "received", + "webhook_id": webhook_id or None, + "field": field or None, + "sentdm_message_id": sentdm_message_id or None, + "record_id": record_id, + "record_status": record_status, + } diff --git a/jvagent/action/sentdm_broadcast/info.yaml b/jvagent/action/sentdm_broadcast/info.yaml new file mode 100644 index 00000000..3e50f4cc --- /dev/null +++ b/jvagent/action/sentdm_broadcast/info.yaml @@ -0,0 +1,17 @@ +package: + name: jvagent/sentdm_broadcast_action + author: V75 Inc. + archetype: SentDMBroadcastAction + version: 1.0.0 + meta: + title: SentDM Broadcast + description: Send SMS/WhatsApp broadcast messages via the SentDM v3 API. + group: jvagent + type: action + config: + order: + weight: 0 + dependencies: + jvagent: ~0.0.1 + pip: + - httpx>=0.27.0 diff --git a/jvagent/action/sentdm_broadcast/models.py b/jvagent/action/sentdm_broadcast/models.py new file mode 100644 index 00000000..198d73ba --- /dev/null +++ b/jvagent/action/sentdm_broadcast/models.py @@ -0,0 +1,135 @@ +"""Graph nodes for the SentDM broadcast action. + +``SentDMBroadcastRecord`` persists one row per ``(message_id, recipient, +channel)`` we send through SentDM. Webhook delivery events feed updates into +the matching record (looked up by ``sentdm_message_id``), giving us a local +audit trail without re-fetching SentDM every time the status changes. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from jvspatial.core import Node +from jvspatial.core.annotations import attribute +from pydantic import field_validator + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class SentDMBroadcastRecord(Node): + """One persisted record per broadcast message we send via SentDM. + + Looked up by ``sentdm_message_id`` from inbound webhook events so that + delivery status changes can be folded into ``status``, ``events`` and + ``last_event_payload`` without re-fetching SentDM. + """ + + action_id: str = attribute( + indexed=True, + default_factory=str, + description="ID of the SentDMBroadcastAction that produced this record", + ) + agent_id: str = attribute( + indexed=True, + default_factory=str, + description="ID of the agent that owns the action (cross-action lookups)", + ) + sentdm_message_id: str = attribute( + indexed=True, + default_factory=str, + description=( + "SentDM-issued message id (POST /v3/messages returns one per " + "(recipient, channel) pair). Primary lookup key for webhooks." + ), + ) + to: str = attribute( + default_factory=str, + description="Recipient phone number in E.164 format", + ) + channel: str = attribute( + default_factory=str, + description="Delivery channel: 'sms' or 'whatsapp'", + ) + template_id: Optional[str] = attribute( + default=None, + description="Template UUID used for this broadcast", + ) + template_name: Optional[str] = attribute( + default=None, + description="Template name used for this broadcast", + ) + parameters: Dict[str, Any] = attribute( + default_factory=dict, + description="Template parameters substituted into the message", + ) + idempotency_key: Optional[str] = attribute( + default=None, + description="The idempotency-key header value used on the send call", + ) + profile_id: Optional[str] = attribute( + default=None, + description="x-profile-id header value used on the send call", + ) + sandbox: bool = attribute( + default=False, + description="True when the send was made with SentDM sandbox mode", + ) + + status: str = attribute( + default="accepted", + description=( + "Last-known status: accepted | processing | queued | sent | delivered | " + "read | received | failed | rejected | undelivered | unknown" + ), + ) + last_event_field: Optional[str] = attribute( + default=None, + description="Last webhook 'field' value applied (e.g. 'messages')", + ) + last_event_payload: Optional[Dict[str, Any]] = attribute( + default=None, + description=( + "Last webhook event payload (trimmed: ids, channel, status, sub_type)" + ), + ) + last_status_at: Optional[datetime] = attribute( + default=None, + description="When status last changed (server-truth or webhook)", + ) + events: List[Dict[str, Any]] = attribute( + default_factory=list, + description=( + "Bounded append-only audit log of webhook / refresh events. " + "Cap controlled by SentDMBroadcastAction.record_event_history_limit." + ), + ) + error: Optional[Dict[str, Any]] = attribute( + default=None, + description="Populated when status is failed/rejected", + ) + + created_at: datetime = attribute( + default_factory=_utcnow, + description="When this record was first persisted", + ) + updated_at: datetime = attribute( + default_factory=_utcnow, + description="When this record was last updated", + ) + + @field_validator("last_status_at", "created_at", "updated_at", mode="before") + @classmethod + def _coerce_datetime(cls, v: Any) -> Any: + if v is None or isinstance(v, datetime): + return v + if isinstance(v, str): + try: + return datetime.fromisoformat(v.replace("Z", "+00:00")) + except ValueError: + return None + return v + + +__all__ = ["SentDMBroadcastRecord"] diff --git a/jvagent/action/sentdm_broadcast/sentdm_broadcast_action.py b/jvagent/action/sentdm_broadcast/sentdm_broadcast_action.py new file mode 100644 index 00000000..122bcd2a --- /dev/null +++ b/jvagent/action/sentdm_broadcast/sentdm_broadcast_action.py @@ -0,0 +1,1566 @@ +"""SentDM Broadcast Action implementation.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union +from urllib.parse import urlsplit + +import httpx +from jvspatial.api.auth.api_key_service import APIKeyService +from jvspatial.core.annotations import attribute +from jvspatial.core.context import GraphContext +from jvspatial.db import get_prime_database +from jvspatial.env import env +from jvspatial.exceptions import DatabaseError, ValidationError + +from jvagent.action.base import Action +from jvagent.core.public_url import get_public_base_url + +from .models import SentDMBroadcastRecord +from .webhook_auth import get_or_create_system_user + +logger = logging.getLogger(__name__) + + +def _sentdm_webhook_endpoint_url(ep: Mapping[str, Any]) -> str: + for key in ( + "endpoint_url", + "endpointUrl", + "url", + "callback_url", + "callbackUrl", + "webhook_url", + "webhookUrl", + ): + raw = ep.get(key) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return "" + + +def _sentdm_webhook_record_id(ep: Mapping[str, Any]) -> str: + for key in ("id", "webhook_id", "webhookId", "uuid", "UUID"): + raw = ep.get(key) + if raw is not None and str(raw).strip(): + return str(raw).strip() + return "" + + +def _extract_sentdm_webhook_list_items(body: Any) -> List[Dict[str, Any]]: + """Unwrap ``GET /v3/webhooks`` JSON into webhook row dicts (handles nested ``data``).""" + acc: List[Dict[str, Any]] = [] + + def walk(node: Any) -> None: + if isinstance(node, list): + for it in node: + walk(it) + return + if not isinstance(node, dict): + return + branched = False + for key in ("data", "items", "webhooks", "results", "records", "rows"): + child = node.get(key) + if isinstance(child, list): + branched = True + for it in child: + walk(it) + elif isinstance(child, dict): + branched = True + walk(child) + if branched: + return + if _sentdm_webhook_endpoint_url(node) or _sentdm_webhook_record_id(node): + acc.append(node) + + walk(body) + return acc + + +# Current + legacy inbound paths (reconcile deletes stale rows on the same host). +_JVAGENT_SENTDM_WEBHOOK_PATH_PREFIXES: Tuple[str, ...] = ( + "/api/webhook/", + "/api/sentdm/webhook/", +) + + +def _sentdm_webhook_url_on_public_origin(ep_url: str, public_base: str) -> bool: + """True when ``ep_url`` is our jvagent SentDM inbound URL on ``public_base``.""" + ep = urlsplit((ep_url or "").strip()) + base = urlsplit((public_base or "").strip()) + if (ep.scheme or "https").lower() != (base.scheme or "https").lower(): + return False + if ep.netloc.lower() != base.netloc.lower(): + return False + path = ep.path or "/" + return any(path.startswith(p) for p in _JVAGENT_SENTDM_WEBHOOK_PATH_PREFIXES) + + +def _sentdm_webhook_urls_equivalent(a: str, b: str) -> bool: + """True for identical URLs or same origin+path when Sent omits ``?api_key=`` in list APIs.""" + sa = (a or "").strip() + sb = (b or "").strip() + if sa == sb: + return True + pa, pb = urlsplit(sa), urlsplit(sb) + if pa.netloc.lower() != pb.netloc.lower(): + return False + if (pa.scheme or "https").lower() != (pb.scheme or "https").lower(): + return False + path_a = (pa.path or "/").rstrip("/") or "/" + path_b = (pb.path or "/").rstrip("/") or "/" + return path_a == path_b + + +# Sent's POST /v3/webhooks expects ``message`` (singular) for message lifecycle +# events; older bundled docs used ``messages`` — we normalize when calling the API. +_SENTDM_WEBHOOK_EVENT_API_VALUE = { + "message": "message", + "messages": "message", + "templates": "templates", +} +_DEFAULT_WEBHOOK_DISPLAY_NAME = "jvagent SentDM" +_DEFAULT_WEBHOOK_EVENT_FILTERS: Dict[str, List[str]] = { + "message": ["queued", "sent", "delivered", "read", "failed", "received"], +} + + +class SentDMBroadcastAction(Action): + """Send broadcast SMS / WhatsApp messages via the SentDM v3 API. + + Sending requires a template that already exists in your SentDM account. + The API supports multi-channel fan-out via the ``channel`` array — a + single request produces a separate message per ``(recipient, channel)`` + pair. + + Configure the API key via the ``SENTDM_API_KEY`` environment variable. + Webhook auto-registration additionally requires ``JVAGENT_PUBLIC_BASE_URL`` + and ``JVSPATIAL_JWT_SECRET_KEY``. + + Example usage:: + + sentdm = await agent.get_action("SentDMBroadcastAction") + result = await sentdm.send_broadcast( + to=["+14155551234"], + template={"name": "order_confirmation", "parameters": {"name": "Jane"}}, + channels=["sms", "whatsapp"], + ) + """ + + api_base: str = attribute( + default="https://api.sent.dm", + description="Base URL for the SentDM v3 API", + ) + default_channels: List[str] = attribute( + default_factory=lambda: ["sms"], + description=( + "Default channels for send_broadcast when the caller does not pass " + "channels. Values: sms, whatsapp, rcs." + ), + ) + default_template_id: str = attribute( + default="", + description="Fallback template UUID used when send_broadcast omits template.id", + ) + default_template_name: str = attribute( + default="", + description="Fallback template name used when send_broadcast omits template.name", + ) + profile_id: str = attribute( + default="", + description=( + "Optional x-profile-id header value. Required when the API key is an " + "organization key scoped to a child profile." + ), + ) + timeout: int = attribute( + default=30, + description="HTTP request timeout in seconds", + ge=1, + le=300, + ) + sandbox: bool = attribute( + default=False, + description="When true, mutating calls are validated but not executed", + ) + + webhook_display_name: str = attribute( + default=_DEFAULT_WEBHOOK_DISPLAY_NAME, + description="Display name used when creating the SentDM webhook endpoint", + ) + webhook_event_types: List[str] = attribute( + default_factory=lambda: ["message"], + description=( + "Sent webhook parent ``event_types`` for ``POST /v3/webhooks``. Use " + "``message`` (singular) for outbound/inbound message events; " + "``templates`` for template lifecycle. Sub-types (e.g. " + "``message.delivered``) are *not* listed here — use " + "``webhook_event_filters`` instead. Legacy ``messages`` maps to " + "``message``." + ), + ) + webhook_event_filters: Optional[Dict[str, List[str]]] = attribute( + default=None, + description=( + "Optional Sent ``event_filters`` map: parent event type → list of " + "subtype suffixes (e.g. ``{'message': ['sent', 'delivered']}``). " + "``None`` applies a broadcast-focused default " + "(queued, sent, delivered, read, failed, received). ``{}`` omits filters (all " + "sub-types for subscribed parents)." + ), + ) + webhook_retry_count: int = attribute( + default=3, + description="Retry count SentDM uses when delivering webhook events", + ge=0, + le=10, + ) + webhook_timeout_seconds: int = attribute( + default=30, + description="Delivery timeout (seconds) for SentDM webhook calls", + ge=1, + le=300, + ) + + persist_records: bool = attribute( + default=True, + description=( + "Persist a SentDMBroadcastRecord per (recipient, channel) on each " + "send so webhook events can be folded back into the graph." + ), + ) + persist_sandbox_sends: bool = attribute( + default=False, + description=( + "Persist records for sandbox sends too. Off by default to keep the " + "graph free of test traffic." + ), + ) + record_event_history_limit: int = attribute( + default=25, + description=( + "Maximum entries kept in SentDMBroadcastRecord.events. Older entries " + "are dropped FIFO." + ), + ge=1, + le=500, + ) + + webhook_url: Optional[str] = attribute( + default=None, + description="Public webhook URL given to SentDM (auto-generated)", + ) + webhook_api_key_id: Optional[str] = attribute( + default=None, + description="ID of the jvspatial API key used to authenticate inbound webhooks", + ) + sentdm_webhook_id: Optional[str] = attribute( + default=None, + description="ID of the webhook record created in SentDM", + ) + sentdm_webhook_secret: Optional[str] = attribute( + default=None, + description="HMAC signing secret returned by SentDM when the webhook was created", + ) + + # --- env / configuration helpers --------------------------------------- + + @staticmethod + def _env_api_key() -> str: + return (env("SENTDM_API_KEY") or "").strip() + + def is_configured(self) -> bool: + """True when the API key is present (the minimum to make any call).""" + return bool(self._env_api_key()) + + def _config_issues(self) -> List[str]: + issues: List[str] = [] + if not self._env_api_key(): + issues.append("SENTDM_API_KEY is not set") + if not (self.api_base or "").startswith(("http://", "https://")): + issues.append("api_base must be an http/https URL") + return issues + + def get_capabilities(self) -> List[str]: + """Return broadcast capabilities for PersonaAction when enabled.""" + if not self.enabled or not self.is_configured(): + return [] + return [ + "Send template-based SMS or WhatsApp broadcasts via the SentDM API.", + ] + + # --- HTTP plumbing ----------------------------------------------------- + + def _effective_profile_id(self, override: Optional[str] = None) -> str: + if override and str(override).strip(): + return str(override).strip() + return (self.profile_id or "").strip() + + def _headers( + self, + *, + idempotency_key: Optional[str] = None, + profile_id: Optional[str] = None, + json_content: bool = True, + ) -> Dict[str, str]: + api_key = self._env_api_key() + if not api_key: + raise ValidationError("SENTDM_API_KEY is not configured") + headers: Dict[str, str] = { + "accept": "application/json", + "x-api-key": api_key, + } + if json_content: + headers["content-type"] = "application/json" + idem = (idempotency_key or "").strip() + if idem: + headers["idempotency-key"] = idem + pid = self._effective_profile_id(profile_id) + if pid: + headers["x-profile-id"] = pid + return headers + + def _url(self, path: str) -> str: + return f"{(self.api_base or 'https://api.sent.dm').rstrip('/')}{path}" + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json_body: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + profile_id: Optional[str] = None, + ) -> Any: + """Issue an HTTP request and return parsed JSON (or raise on error).""" + headers = self._headers( + idempotency_key=idempotency_key, + profile_id=profile_id, + json_content=json_body is not None, + ) + url = self._url(path) + clean_params = { + k: v for k, v in (params or {}).items() if v is not None and v != "" + } + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + response = await client.request( + method.upper(), + url, + params=clean_params or None, + json=json_body, + headers=headers, + ) + except httpx.HTTPError as exc: + logger.error( + "SentDM %s %s transport error: %s", method.upper(), path, exc + ) + raise + try: + body: Any = response.json() + except ValueError: + body = response.text + if not response.is_success: + details: Any = None + if isinstance(body, dict): + err = body.get("error") + if isinstance(err, dict): + details = err.get("details") + logger.error( + "SentDM %s %s failed (http=%s): %s", + method.upper(), + path, + response.status_code, + body, + ) + if details is not None: + logger.error("SentDM validation details: %s", details) + response.raise_for_status() + return body + + # --- core API methods -------------------------------------------------- + + def _resolve_template( + self, + template: Optional[Mapping[str, Any]], + parameters: Optional[Mapping[str, Any]] = None, + ) -> Dict[str, Any]: + """Resolve template (id|name + parameters) with fallback to action defaults.""" + tmpl: Dict[str, Any] = dict(template or {}) + tmpl_id = str(tmpl.get("id") or "").strip() + tmpl_name = str(tmpl.get("name") or "").strip() + if not tmpl_id and not tmpl_name: + tmpl_id = (self.default_template_id or "").strip() + tmpl_name = (self.default_template_name or "").strip() + if not tmpl_id and not tmpl_name: + raise ValidationError( + "send_broadcast requires a template id or name (either on the " + "call or via default_template_id/default_template_name)" + ) + + resolved: Dict[str, Any] = {} + if tmpl_id: + resolved["id"] = tmpl_id + if tmpl_name: + resolved["name"] = tmpl_name + + merged_params: Dict[str, Any] = {} + existing_params = tmpl.get("parameters") + if isinstance(existing_params, Mapping): + merged_params.update(existing_params) + if parameters: + merged_params.update(parameters) + resolved["parameters"] = merged_params + return resolved + + async def send_broadcast( + self, + to: Union[str, Sequence[str]], + template: Optional[Mapping[str, Any]] = None, + *, + channels: Optional[Sequence[str]] = None, + parameters: Optional[Mapping[str, Any]] = None, + sandbox: Optional[bool] = None, + idempotency_key: Optional[str] = None, + profile_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Send a broadcast via ``POST /v3/messages``. + + Args: + to: Recipient phone number (E.164) or list of phone numbers. + template: Template descriptor ``{"id"?: str, "name"?: str, + "parameters"?: dict}``. At least one of ``id`` / ``name`` must + resolve (per-call or via action defaults). + channels: Channel list for fan-out (defaults to ``default_channels``). + parameters: Variable substitutions merged on top of + ``template["parameters"]``. + sandbox: Per-call override for sandbox mode. + idempotency_key: Optional ``idempotency-key`` header value. + profile_id: Override the action's ``x-profile-id``. + + Returns: + Raw JSON response from SentDM. + """ + recipients: List[str] + if isinstance(to, str): + recipients = [to] + else: + recipients = [str(r) for r in to] + if not recipients: + raise ValidationError("send_broadcast requires at least one recipient") + + channel_list = [str(c) for c in (channels or self.default_channels)] + if not channel_list: + raise ValidationError( + "send_broadcast requires at least one channel " + "(action.default_channels is empty and none were passed)" + ) + + resolved_template = self._resolve_template(template, parameters) + effective_sandbox = bool(self.sandbox if sandbox is None else sandbox) + + payload: Dict[str, Any] = { + "to": recipients, + "channel": channel_list, + "template": resolved_template, + "sandbox": effective_sandbox, + } + + response = await self._request( + "POST", + "/v3/messages", + json_body=payload, + idempotency_key=idempotency_key, + profile_id=profile_id, + ) + + try: + await self._persist_send_records( + response=response, + recipients=recipients, + channels=channel_list, + template=resolved_template, + sandbox=effective_sandbox, + idempotency_key=idempotency_key, + profile_id=profile_id, + ) + except Exception as exc: # pragma: no cover - best-effort persistence + logger.warning( + "SentDM send_broadcast succeeded but record persistence failed: %s", + exc, + exc_info=True, + ) + + return response + + # --- broadcast record helpers ----------------------------------------- + + @staticmethod + def _unwrap_sent_v3_envelope(body: Any) -> Any: + """Return ``body['data']`` when ``body`` looks like ``{success, data, meta}``.""" + if not isinstance(body, dict) or "data" not in body: + return body + inner = body.get("data") + if not isinstance(inner, (dict, list)): + return body + if "success" in body or "meta" in body or body.get("ok") is True: + return inner + return body + + @staticmethod + def _dict_has_message_identifier(d: dict) -> bool: + for key in ("id", "message_id", "messageId"): + v = d.get(key) + if isinstance(v, str) and v.strip(): + return True + return False + + @staticmethod + def _collect_message_lists_and_singletons( + node: Any, *, _depth: int = 0 + ) -> List[Dict[str, Any]]: + """Gather dict rows that look like Sent message descriptors from a node.""" + if _depth > 12: + return [] + out: List[Dict[str, Any]] = [] + if isinstance(node, list): + for entry in node: + if isinstance( + entry, dict + ) and SentDMBroadcastAction._dict_has_message_identifier(entry): + out.append(entry) + return out + if not isinstance(node, dict): + return out + + for key in ( + "messages", + "results", + "items", + "records", + "rows", + "message", + "data", + "Messages", + "Results", + ): + if key not in node: + continue + value = node.get(key) + if isinstance(value, list): + for entry in value: + if isinstance( + entry, dict + ) and SentDMBroadcastAction._dict_has_message_identifier(entry): + out.append(entry) + if out: + return out + if isinstance(value, dict): + if SentDMBroadcastAction._dict_has_message_identifier(value): + out.append(value) + return out + nested = SentDMBroadcastAction._collect_message_lists_and_singletons( + value, _depth=_depth + 1 + ) + if nested: + return nested + + if SentDMBroadcastAction._dict_has_message_identifier(node): + out.append(node) + return out + msg = node.get("message") + if isinstance(msg, dict) and SentDMBroadcastAction._dict_has_message_identifier( + msg + ): + out.append(msg) + return out + return out + + @staticmethod + def _walk_sent_tree_for_message_dicts( + obj: Any, *, _depth: int = 0, _seen: Optional[set] = None + ) -> List[Dict[str, Any]]: + """Last-resort scan for message-shaped dicts (nested ``results.messages``, etc.).""" + if _depth > 14 or obj is None: + return [] + if _seen is None: + _seen = set() + found: List[Dict[str, Any]] = [] + if isinstance(obj, dict): + oid = id(obj) + if oid in _seen: + return [] + _seen.add(oid) + if SentDMBroadcastAction._dict_has_message_identifier(obj): + hint_keys = ( + "to", + "channel", + "message_status", + "messageStatus", + "status", + "template_id", + "templateId", + "sandbox", + "recipient", + "phone", + ) + if any(k in obj for k in hint_keys): + return [obj] + if _depth >= 2: + rid = str( + obj.get("id") + or obj.get("message_id") + or obj.get("messageId") + or "" + ).strip() + if len(rid) >= 32 and rid.count("-") >= 4: + return [obj] + for k, v in obj.items(): + if k in ("meta",) and isinstance(v, dict) and len(v) > 8: + continue + found.extend( + SentDMBroadcastAction._walk_sent_tree_for_message_dicts( + v, _depth=_depth + 1, _seen=_seen + ) + ) + elif isinstance(obj, list): + for it in obj: + found.extend( + SentDMBroadcastAction._walk_sent_tree_for_message_dicts( + it, _depth=_depth + 1, _seen=_seen + ) + ) + return found + + @staticmethod + def _extract_sent_message_descriptors( + response: Any, + ) -> List[Dict[str, Any]]: + """Extract per-message descriptors from a ``POST /v3/messages`` response. + + SentDM's response shape is not fully documented and may evolve, so we + probe a few candidate locations and collect anything that looks like a + message descriptor (i.e. has an ``id`` / ``message_id``). The + descriptors are returned verbatim so callers can read ``id``, ``to``, + ``channel``, ``status`` etc. with whatever names SentDM uses. + + Recent APIs wrap payloads as ``{success, data, meta}`` where ``data`` + holds ``messages`` or a single message object — older code only treated + top-level ``data`` as a list and missed those rows. + """ + cur: Any = response + for _ in range(3): + nxt = SentDMBroadcastAction._unwrap_sent_v3_envelope(cur) + if nxt is cur: + break + cur = nxt + + if isinstance(cur, list): + return SentDMBroadcastAction._collect_message_lists_and_singletons(cur) + + if isinstance(cur, dict): + found = SentDMBroadcastAction._collect_message_lists_and_singletons(cur) + if found: + return found + # ``data`` may be a nested envelope or message bag + inner = cur.get("data") + if isinstance(inner, (dict, list)): + found = SentDMBroadcastAction._collect_message_lists_and_singletons( + inner + ) + if found: + return found + + if isinstance(cur, (dict, list)): + scanned = SentDMBroadcastAction._walk_sent_tree_for_message_dicts(cur) + if scanned: + # De-dupe by message id + by_id: Dict[str, Dict[str, Any]] = {} + for row in scanned: + rid = str( + row.get("id") + or row.get("message_id") + or row.get("messageId") + or "" + ).strip() + if rid: + by_id[rid] = row + return list(by_id.values()) + + return [] + + async def _persist_send_records( + self, + *, + response: Any, + recipients: Sequence[str], + channels: Sequence[str], + template: Mapping[str, Any], + sandbox: bool, + idempotency_key: Optional[str], + profile_id: Optional[str], + ) -> List[SentDMBroadcastRecord]: + """Create a SentDMBroadcastRecord per (message_id, recipient, channel).""" + if not self.persist_records: + return [] + if sandbox and not self.persist_sandbox_sends: + logger.info( + "SentDM persist: skipping SentDMBroadcastRecord creation " + "(sandbox send; set persist_sandbox_sends=true on the action to record)" + ) + return [] + + descriptors = self._extract_sent_message_descriptors(response) + if not descriptors: + data_keys: Optional[List[str]] = None + if isinstance(response, dict) and isinstance(response.get("data"), dict): + data_keys = list(response["data"].keys())[:32] + logger.info( + "SentDM persist: no message descriptors in POST /v3/messages response; " + "skipping SentDMBroadcastRecord (type=%s keys=%s data_keys=%s)", + type(response).__name__, + list(response.keys()) if isinstance(response, dict) else None, + data_keys, + ) + return [] + + agent = await self.get_agent() + agent_id = str(getattr(agent, "id", "") or "") + + template_id = str(template.get("id") or "") or None + template_name = str(template.get("name") or "") or None + template_params = template.get("parameters") or {} + if not isinstance(template_params, dict): + template_params = {} + + records: List[SentDMBroadcastRecord] = [] + for desc in descriptors: + sentdm_message_id = str(desc.get("id") or desc.get("message_id") or "") + if not sentdm_message_id: + continue + to_value = str( + desc.get("to") or desc.get("recipient") or desc.get("phone") or "" + ) + if not to_value and len(recipients) == 1: + to_value = recipients[0] + channel_value = str(desc.get("channel") or "") + if not channel_value and len(channels) == 1: + channel_value = channels[0] + + status_value = str(desc.get("status") or "accepted").lower() or "accepted" + + record = await SentDMBroadcastRecord.create( + action_id=str(self.id), + agent_id=agent_id, + sentdm_message_id=sentdm_message_id, + to=to_value, + channel=channel_value, + template_id=template_id, + template_name=template_name, + parameters=dict(template_params), + idempotency_key=idempotency_key or None, + profile_id=self._effective_profile_id(profile_id) or None, + sandbox=sandbox, + status=status_value, + last_event_field="send", + last_event_payload=desc, + last_status_at=datetime.now(timezone.utc), + events=[ + { + "field": "send", + "status": status_value, + "payload": desc, + "received_at": datetime.now(timezone.utc).isoformat(), + } + ], + ) + try: + await self.connect(record) + except Exception as exc: # pragma: no cover - non-fatal + logger.debug( + "SentDM persist: failed to connect action->record edge: %s", exc + ) + records.append(record) + + if records: + logger.debug( + "SentDM persist: stored %d broadcast record(s) for action %s", + len(records), + self.id, + ) + return records + + async def _record_for_message_id( + self, message_id: str + ) -> Optional[SentDMBroadcastRecord]: + """Look up the broadcast record for a SentDM message id.""" + key = (message_id or "").strip() + if not key: + return None + try: + result = await SentDMBroadcastRecord.find_one( + action_id=str(self.id), sentdm_message_id=key + ) + except Exception as exc: + logger.warning("SentDM persist: find_one failed for %s: %s", key, exc) + return None + if result is None or not isinstance(result, SentDMBroadcastRecord): + return None + return result + + async def _create_record_from_webhook_fold( + self, + field: str, + fold: Dict[str, Any], + message_id: str, + ) -> SentDMBroadcastRecord: + """Create a minimal ``SentDMBroadcastRecord`` from the first webhook payload.""" + body = fold if isinstance(fold, dict) else {} + mid = (message_id or "").strip() + if not mid: + raise ValueError("message_id is required to create a webhook record") + + outbound = str(body.get("outbound_number") or "").strip() + to_val = "" if outbound.lower() == "unknown" else outbound + + chan = str(body.get("channel") or "").strip() + if chan.lower() == "unknown": + chan = "" + + tid_raw = body.get("template_id") + template_id: Optional[str] + if tid_raw is None or (isinstance(tid_raw, str) and not tid_raw.strip()): + template_id = None + else: + template_id = str(tid_raw).strip() or None + + record = await SentDMBroadcastRecord.create( + action_id=str(self.id), + agent_id=str(self.agent_id or ""), + sentdm_message_id=mid, + to=to_val, + channel=chan, + template_id=template_id, + template_name=None, + parameters={}, + idempotency_key=None, + profile_id=None, + sandbox=False, + status="accepted", + last_event_field=None, + last_event_payload=None, + last_status_at=None, + events=[], + error=None, + ) + try: + await self.connect(record) + except Exception as exc: # pragma: no cover - non-fatal + logger.debug( + "SentDM webhook upsert: connect action->record failed: %s", exc + ) + logger.info( + "SentDM webhook: created SentDMBroadcastRecord for message_id=%s " + "(field=%s action=%s)", + mid, + field or "(unknown)", + self.id, + ) + return record + + async def apply_webhook_with_upsert( + self, + field: str, + fold: Dict[str, Any], + message_id: str, + ) -> Optional[SentDMBroadcastRecord]: + """Find or create the graph record for ``message_id`` and fold in this event.""" + mid = (message_id or "").strip() + if not mid: + return None + + record = await self._record_for_message_id(mid) + if record is None: + record = await self._create_record_from_webhook_fold(field, fold, mid) + return await self._apply_webhook_event_to_record(record, field, fold) + + @staticmethod + def _derive_status_and_error( + field: str, + payload: Any, + ) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: + """Extract a normalized status (and optional error) from a webhook event. + + Resolution order: + + 1. ``payload.status`` / ``payload.message_status`` (or nested ``data``) + when it matches a known status string (case-insensitive). + 2. Event-name suffix when ``payload.event``, ``event_type``, ``eventType``, + or ``sub_type`` looks like ``message.delivered`` / ``message.sent`` / etc. + 3. None when nothing matched (caller keeps the prior status). + """ + known = { + "accepted", + "queued", + "processing", + "routed", + "sent", + "delivered", + "read", + "failed", + "rejected", + "undelivered", + "expired", + "unknown", + "received", + } + + def _normalize(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + v = value.strip().lower() + if not v: + return None + if v in known: + return v + return None + + body: Dict[str, Any] = payload if isinstance(payload, dict) else {} + status = _normalize(body.get("status")) + if not status: + status = _normalize(body.get("message_status")) + if not status and isinstance(body.get("data"), dict): + status = _normalize(body["data"].get("status")) + if not status and isinstance(body.get("data"), dict): + status = _normalize(body["data"].get("message_status")) + + if not status: + event_name = ( + body.get("event") + or body.get("event_type") + or body.get("eventType") + or body.get("sub_type") + ) + if isinstance(event_name, str) and "." in event_name: + suffix = event_name.split(".", 1)[1].strip().lower() + status = _normalize(suffix) + + error_payload: Optional[Dict[str, Any]] = None + if status in {"failed", "rejected", "undelivered"}: + err = body.get("error") or body.get("failure_reason") or body.get("reason") + if isinstance(err, dict): + error_payload = err + elif isinstance(err, str) and err.strip(): + error_payload = {"message": err.strip()} + + # field is reserved for callers; not used to derive status yet but + # available for future heuristics. + _ = field + return status, error_payload + + _WEBHOOK_AUDIT_KEYS = frozenset( + { + "sub_type", + "message_id", + "sentdm_message_id", + "id", + "status", + "message_status", + "channel", + "account_id", + "template_id", + "inbound_number", + "outbound_number", + } + ) + + @staticmethod + def _compact_webhook_audit_payload(payload: Any) -> Optional[Dict[str, Any]]: + """Persist only Sent message fields useful for status correlation / support.""" + if not isinstance(payload, dict): + return None + out = { + k: payload[k] + for k in SentDMBroadcastAction._WEBHOOK_AUDIT_KEYS + if k in payload and payload[k] is not None and str(payload[k]).strip() != "" + } + return out or None + + async def _apply_webhook_event_to_record( + self, + record: SentDMBroadcastRecord, + field: str, + payload: Any, + *, + source: str = "webhook", + ) -> SentDMBroadcastRecord: + """Fold a webhook (or refresh) event into a broadcast record.""" + new_status, error_payload = self._derive_status_and_error(field, payload) + now = datetime.now(timezone.utc) + + stored = self._compact_webhook_audit_payload(payload) + event_entry: Dict[str, Any] = { + "source": source, + "field": field or None, + "status": new_status, + "received_at": now.isoformat(), + "payload": stored, + } + events = list(record.events or []) + events.append(event_entry) + cap = max(int(self.record_event_history_limit or 1), 1) + if len(events) > cap: + events = events[-cap:] + record.events = events + + record.last_event_field = field or record.last_event_field + if stored is not None: + record.last_event_payload = stored + + if new_status and new_status != record.status: + record.status = new_status + record.last_status_at = now + elif new_status: + record.last_status_at = now + + if error_payload: + record.error = error_payload + elif new_status and new_status not in {"failed", "rejected", "undelivered"}: + record.error = None + + record.updated_at = now + await record.save() + return record + + async def refresh_record( + self, + record_id: str, + ) -> Dict[str, Any]: + """Re-fetch the latest server-truth status for one record. + + Useful when a webhook was missed or delivered to a different replica. + Calls ``GET /v3/messages/{sentdm_message_id}`` and folds the result + into the record's audit log. + """ + if not record_id: + raise ValidationError("record_id is required") + record = await SentDMBroadcastRecord.get(record_id) + if record is None or not isinstance(record, SentDMBroadcastRecord): + raise ValidationError(f"SentDMBroadcastRecord not found: {record_id}") + if str(getattr(record, "action_id", "")) != str(self.id): + raise ValidationError( + f"Record {record_id} does not belong to action {self.id}" + ) + if not record.sentdm_message_id: + raise ValidationError( + f"Record {record_id} has no sentdm_message_id; cannot refresh" + ) + + status_body = await self.get_message_status( + record.sentdm_message_id, profile_id=record.profile_id or None + ) + fold: Dict[str, Any] + if isinstance(status_body, dict): + inner = status_body.get("data") + fold = dict(inner) if isinstance(inner, dict) else dict(status_body) + else: + fold = {} + updated = await self._apply_webhook_event_to_record( + record, "refresh", fold, source="refresh" + ) + return { + "record_id": updated.id, + "sentdm_message_id": updated.sentdm_message_id, + "status": updated.status, + "last_status_at": ( + updated.last_status_at.isoformat() if updated.last_status_at else None + ), + "upstream": status_body, + } + + async def get_message_status( + self, + message_id: str, + *, + profile_id: Optional[str] = None, + ) -> Dict[str, Any]: + """``GET /v3/messages/{id}``.""" + if not message_id: + raise ValidationError("message_id is required") + return await self._request( + "GET", f"/v3/messages/{message_id}", profile_id=profile_id + ) + + async def get_message_activities( + self, + message_id: str, + *, + profile_id: Optional[str] = None, + ) -> Dict[str, Any]: + """``GET /v3/messages/{id}/activities``.""" + if not message_id: + raise ValidationError("message_id is required") + return await self._request( + "GET", + f"/v3/messages/{message_id}/activities", + profile_id=profile_id, + ) + + async def list_templates( + self, + *, + page: Optional[int] = None, + page_size: Optional[int] = None, + search: Optional[str] = None, + status: Optional[str] = None, + category: Optional[str] = None, + profile_id: Optional[str] = None, + ) -> Dict[str, Any]: + """``GET /v3/templates`` with optional filters.""" + params: Dict[str, Any] = { + "page": page, + "page_size": page_size, + "search": search, + "status": status, + "category": category, + } + return await self._request( + "GET", "/v3/templates", params=params, profile_id=profile_id + ) + + async def get_account(self, *, profile_id: Optional[str] = None) -> Dict[str, Any]: + """``GET /v3/me`` — account identity and configured channels.""" + return await self._request("GET", "/v3/me", profile_id=profile_id) + + async def healthcheck(self) -> Union[bool, Dict[str, Any]]: + """Lightweight healthcheck — pings ``/v3/me`` when configured.""" + if not self.is_configured(): + return { + "healthy": True, + "configured": False, + "status": "inactive", + "message": "SentDM action is not configured", + "issues": self._config_issues(), + } + + try: + account = await self.get_account() + except httpx.HTTPStatusError as exc: + return { + "healthy": False, + "configured": True, + "status": "error", + "message": f"SentDM /v3/me returned {exc.response.status_code}", + } + except httpx.HTTPError as exc: + return { + "healthy": False, + "configured": True, + "status": "error", + "message": f"SentDM /v3/me transport error: {exc}", + } + except Exception as exc: # pragma: no cover - defensive + logger.error("SentDM healthcheck error: %s", exc, exc_info=True) + return { + "healthy": False, + "configured": True, + "status": "error", + "message": str(exc), + } + + channels: Dict[str, Any] = {} + if isinstance(account, dict): + raw_channels = account.get("channels") + if isinstance(raw_channels, dict): + channels = { + name: bool((info or {}).get("configured")) + for name, info in raw_channels.items() + if isinstance(info, dict) + } + + return { + "healthy": True, + "configured": True, + "status": "active", + "api_base": self.api_base, + "default_channels": list(self.default_channels or []), + "channels": channels, + "webhook_registered": bool(self.sentdm_webhook_id), + } + + # --- webhook URL / system user ---------------------------------------- + + def _expected_webhook_url_base(self, base_url: str) -> str: + return f"{base_url.rstrip('/')}/api/webhook/{str(self.id)}" + + @staticmethod + def _sentdm_webhook_path_prefix_for_base(base_url: str) -> str: + """URL prefix for any jvagent SentDM webhook on this public host. + + Matches ``/api/webhook/`` (and legacy + ``/api/sentdm/webhook/``) so reconcile can remove stale Sent endpoints + on the same ``JVAGENT_PUBLIC_BASE_URL``. + """ + return f"{(base_url or '').rstrip('/')}/api/webhook/" + + async def get_webhook_url( + self, + *, + allowed_ip: Optional[str] = None, + regenerate: bool = False, + ) -> str: + """Generate (or retrieve) a secure webhook URL with API-key auth. + + Mirrors :meth:`jvagent.action.whatsapp.whatsapp_action.WhatsAppAction.get_webhook_url` + — the URL embeds an ``api_key`` query param backed by a jvspatial API + key owned by a dedicated system user. The plaintext key is only known + at creation time, so the URL is persisted on the action. + """ + base_url = get_public_base_url() + if not base_url or not base_url.strip(): + raise ValidationError( + "JVAGENT_PUBLIC_BASE_URL is required for webhook URL generation" + ) + if not base_url.startswith(("http://", "https://")): + raise ValidationError( + f"JVAGENT_PUBLIC_BASE_URL must be a valid HTTP/HTTPS URL, got: {base_url}" + ) + + try: + expected_url_base = self._expected_webhook_url_base(base_url) + prime_ctx = GraphContext(database=get_prime_database()) + api_key_service = APIKeyService(context=prime_ctx) + + if ( + not regenerate + and self.webhook_url + and "?api_key=" in self.webhook_url + and self.webhook_url.startswith(expected_url_base) + ): + if allowed_ip is not None and self.webhook_api_key_id: + try: + existing_key = await api_key_service.get_key( + self.webhook_api_key_id + ) + if existing_key and existing_key.is_active: + requested_ips = [allowed_ip] if allowed_ip else [] + existing_ips = ( + getattr(existing_key, "allowed_ips", None) or [] + ) + if requested_ips == existing_ips: + return self.webhook_url + except Exception: + pass + else: + return self.webhook_url + + system_user_id = await get_or_create_system_user() + + if regenerate and self.webhook_api_key_id: + try: + await api_key_service.revoke_key( + self.webhook_api_key_id, system_user_id + ) + except Exception: + pass + + agent = await self.get_agent() + agent_name = getattr(agent, "name", None) or "agent" + + plaintext_key, api_key = await api_key_service.generate_key( + user_id=system_user_id, + name=f"SentDM Webhook - {agent_name}", + permissions=["webhook:sentdm"], + expires_in_days=None, + allowed_ips=[allowed_ip] if allowed_ip else [], + allowed_endpoints=[ + "/api/webhook/*", + "/api/sentdm/webhook/*", + ], + key_prefix="jv_", + ) + + self.webhook_api_key_id = api_key.id + self.webhook_url = f"{expected_url_base}?api_key={plaintext_key}" + await self.save() + return self.webhook_url + + except DatabaseError: + raise + except ValidationError: + raise + except Exception as exc: + raise ValidationError(f"Webhook URL generation failed: {exc}") + + # --- SentDM webhook CRUD ---------------------------------------------- + + async def _sentdm_webhook_list(self) -> List[Dict[str, Any]]: + """List webhook endpoints registered on the SentDM account (all pages).""" + merged: Dict[str, Dict[str, Any]] = {} + page = 1 + while page <= 100: + body = await self._request( + "GET", + "/v3/webhooks", + params={"page": str(page), "page_size": "100"}, + ) + batch = _extract_sentdm_webhook_list_items(body) + if not batch: + break + for ep in batch: + wid = _sentdm_webhook_record_id(ep) + ep_url = _sentdm_webhook_endpoint_url(ep) + key = wid or f"url:{ep_url}" + merged[key] = ep + if len(batch) < 100: + break + page += 1 + return list(merged.values()) + + async def _sentdm_webhook_create( + self, + endpoint_url: str, + *, + display_name: Optional[str] = None, + event_types: Optional[Sequence[str]] = None, + retry_count: Optional[int] = None, + timeout_seconds: Optional[int] = None, + ) -> Dict[str, Any]: + """Create a webhook in SentDM and capture its id + signing secret.""" + mapped: List[str] = [] + for raw in event_types or self.webhook_event_types or ["message"]: + key = str(raw).strip().lower() + if key in _SENTDM_WEBHOOK_EVENT_API_VALUE: + mapped.append(_SENTDM_WEBHOOK_EVENT_API_VALUE[key]) + # De-dupe while preserving order + seen: set = set() + events: List[str] = [] + for ev in mapped: + if ev not in seen: + seen.add(ev) + events.append(ev) + if not events: + events = ["message"] + + payload: Dict[str, Any] = { + "display_name": display_name or self.webhook_display_name, + "endpoint_url": endpoint_url, + "event_types": events, + "retry_count": ( + retry_count if retry_count is not None else self.webhook_retry_count + ), + "timeout_seconds": ( + timeout_seconds + if timeout_seconds is not None + else self.webhook_timeout_seconds + ), + "sandbox": False, + } + + filters_raw = self.webhook_event_filters + if filters_raw is None: + payload["event_filters"] = { + k: list(v) for k, v in _DEFAULT_WEBHOOK_EVENT_FILTERS.items() + } + elif filters_raw: + cleaned: Dict[str, List[str]] = {} + for raw_key, raw_list in filters_raw.items(): + if not isinstance(raw_list, list): + continue + key = str(raw_key).strip() + if not key: + continue + items = [str(x).strip() for x in raw_list if str(x).strip()] + if items: + cleaned[key] = items + if cleaned: + payload["event_filters"] = cleaned + + body = await self._request("POST", "/v3/webhooks", json_body=payload) + data = body.get("data") if isinstance(body, dict) and "data" in body else body + if not isinstance(data, dict): + data = body if isinstance(body, dict) else {} + + webhook_id = str(data.get("id") or data.get("webhook_id") or "") + secret = str( + data.get("signing_secret") + or data.get("secret") + or data.get("signingSecret") + or "" + ) + if webhook_id: + self.sentdm_webhook_id = webhook_id + if secret: + self.sentdm_webhook_secret = secret + if webhook_id or secret: + await self.save() + return data + + async def _sentdm_webhook_delete(self, webhook_id: str) -> None: + if not webhook_id: + return + try: + await self._request("DELETE", f"/v3/webhooks/{webhook_id}") + except httpx.HTTPStatusError as exc: + if exc.response.status_code in (404, 410): + return + raise + + async def reconcile_webhook_endpoint(self) -> Dict[str, Any]: + """Ensure SentDM has exactly one webhook for this action's URL. + + - Generates the webhook URL if missing. + - Lists existing SentDM webhooks; keeps an exact ``endpoint_url`` match. + - Deletes other webhooks under the same public host whose path starts with + ``/api/webhook/`` or legacy ``/api/sentdm/webhook/`` (any action id) that + are not the desired URL. + - Creates a new webhook (with signing secret) if no match was kept. + """ + if not self.is_configured(): + return { + "status": "skipped", + "reason": "SentDM action is not configured", + "issues": self._config_issues(), + } + + base_url = get_public_base_url() + if not base_url: + return { + "status": "skipped", + "reason": "JVAGENT_PUBLIC_BASE_URL is not set", + } + + desired_url = await self.get_webhook_url() + + try: + existing = await self._sentdm_webhook_list() + except Exception as exc: + logger.warning("SentDM webhook list failed: %s", exc) + return {"status": "error", "message": f"webhook list failed: {exc}"} + + exact_matches: List[Dict[str, Any]] = [] + stale_matches: List[Dict[str, Any]] = [] + for ep in existing: + ep_url = _sentdm_webhook_endpoint_url(ep) + if not ep_url: + continue + if _sentdm_webhook_urls_equivalent(ep_url, desired_url): + exact_matches.append(ep) + elif _sentdm_webhook_url_on_public_origin( + ep_url, base_url + ) and not _sentdm_webhook_urls_equivalent(ep_url, desired_url): + stale_matches.append(ep) + + deleted: List[str] = [] + for ep in stale_matches: + wid = _sentdm_webhook_record_id(ep) + if not wid: + logger.warning( + "SentDM reconcile: skipping stale webhook with no id (url=%s)", + _sentdm_webhook_endpoint_url(ep) or "?", + ) + continue + try: + await self._sentdm_webhook_delete(wid) + deleted.append(wid) + except Exception as exc: + logger.warning("SentDM: failed deleting stale webhook %s: %s", wid, exc) + + kept: Optional[Dict[str, Any]] = None + if exact_matches: + kept = exact_matches[0] + for ep in exact_matches[1:]: + wid = _sentdm_webhook_record_id(ep) + if not wid: + continue + try: + await self._sentdm_webhook_delete(wid) + deleted.append(wid) + except Exception as exc: + logger.warning( + "SentDM: failed deleting duplicate webhook %s: %s", wid, exc + ) + + created: Optional[Dict[str, Any]] = None + if kept: + wid = _sentdm_webhook_record_id(kept) + if wid and wid != (self.sentdm_webhook_id or ""): + self.sentdm_webhook_id = wid + await self.save() + else: + try: + created = await self._sentdm_webhook_create(desired_url) + kept = created + except Exception as exc: + logger.error("SentDM: webhook create failed: %s", exc) + return { + "status": "error", + "message": f"webhook create failed: {exc}", + "desired_url": desired_url, + "deleted_webhook_ids": deleted, + } + + if not self.sentdm_webhook_secret: + logger.warning( + "SentDM webhook created/kept without a signing secret on record; " + "incoming signature verification will fail. Rotate the secret via " + "POST /v3/webhooks/{id}/rotate-secret and persist it on the action." + ) + + return { + "status": "ok", + "desired_url": desired_url, + "webhook": kept or {}, + "created": created is not None, + "deleted_webhook_ids": deleted, + } + + # --- lifecycle hooks --------------------------------------------------- + + async def on_register(self) -> None: + """Validate configuration and best-effort reconcile the webhook.""" + if not self.is_configured(): + logger.debug( + "SentDMBroadcastAction not configured: %s", + "; ".join(self._config_issues()), + ) + return + await self._try_reconcile_webhook(reason="on_register") + + async def on_reload(self) -> None: + """Re-reconcile webhook so it tracks the current public URL / api key.""" + if not self.is_configured(): + return + await self._try_reconcile_webhook(reason="on_reload") + + async def _try_reconcile_webhook(self, *, reason: str) -> None: + if not get_public_base_url(): + logger.info( + "SentDM webhook reconcile (%s) skipped: JVAGENT_PUBLIC_BASE_URL is not set", + reason, + ) + return + try: + result = await self.reconcile_webhook_endpoint() + if isinstance(result, dict) and result.get("status") == "ok": + logger.info( + "SentDM webhook reconciled (%s): created=%s deleted=%s desired_url=%s", + reason, + result.get("created"), + result.get("deleted_webhook_ids"), + result.get("desired_url"), + ) + elif isinstance(result, dict) and result.get("status") == "skipped": + logger.info( + "SentDM webhook reconcile (%s) skipped: %s", + reason, + result.get("reason"), + ) + else: + logger.warning( + "SentDM webhook reconcile (%s) returned non-ok: %s", reason, result + ) + except Exception as exc: + logger.warning( + "SentDM webhook reconcile (%s) failed: %s", reason, exc, exc_info=True + ) diff --git a/jvagent/action/sentdm_broadcast/test_cli.py b/jvagent/action/sentdm_broadcast/test_cli.py new file mode 100644 index 00000000..d104ebcd --- /dev/null +++ b/jvagent/action/sentdm_broadcast/test_cli.py @@ -0,0 +1,843 @@ +"""Interactive CLI for exercising the SentDMBroadcastAction HTTP endpoints. + +This is a standalone tester — it does NOT import any jvagent code at runtime, +it just talks to a running jvagent server over HTTP. + +Usage:: + + python jvagent/action/sentdm_broadcast/test_cli.py + python jvagent/action/sentdm_broadcast/test_cli.py --env-file path/to/.env + +The script will: + +1. Load a ``.env`` file: by default ``examples/jvagent_app/.env`` in this repo + (resolved from this script's location), or override with ``--env-file``, + with a CWD walk as a fallback if that file is missing. +2. Ask for the jvagent base URL (default derived from + ``JVAGENT_BASE_URL`` / ``JVAGENT_PUBLIC_BASE_URL`` or + ``JVAGENT_HOST`` + ``JVAGENT_PORT``). +3. Authenticate with either admin email/password (``POST /api/auth/login`` + — jvspatial's ``UserLogin`` model expects ``email`` + ``password``) or an + existing jvagent API key (``x-api-key`` header). Defaults are taken from + ``JVAGENT_ADMIN_EMAIL`` (falling back to ``JVAGENT_ADMIN_USERNAME``) / + ``JVAGENT_ADMIN_PASSWORD`` / ``JVAGENT_API_KEY`` / + ``JVAGENT_API_KEY_HEADER`` if present. +4. List agents and let you pick one. +5. Find the SentDMBroadcastAction registered on that agent. +6. Open a menu: send broadcast (few prompts; optional ``SENTDM_TEST_*`` .env + defaults), reconcile webhook, show webhook URL, switch agent, quit. + +Depends on the standard library + ``httpx`` + ``python-dotenv`` (both already +jvagent dependencies). The last successful base_url + auth method are cached +at ``~/.sentdm_test_cli.json`` so reruns are quick. **Passwords and API keys +are NEVER cached to disk.** +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import httpx +except ImportError: # pragma: no cover - guidance for the user + print( + "This script requires the 'httpx' package. " + "Install it with: pip install httpx", + file=sys.stderr, + ) + raise + +try: + from dotenv import dotenv_values, find_dotenv +except ImportError: # pragma: no cover - python-dotenv is a jvagent dep + print( + "This script requires the 'python-dotenv' package " + "(already a jvagent dependency). Install it with: pip install python-dotenv", + file=sys.stderr, + ) + raise + + +CONFIG_PATH = Path.home() / ".sentdm_test_cli.json" +ACTION_LABEL = "SentDMBroadcastAction" +ACTION_NAMES = { + "sentdm_broadcast_action", + "jvagent/sentdm_broadcast_action", +} +ACTION_ID_PREFIX = "n.SentDMBroadcastAction." + +# Module-level flag toggled by --yes / -y. When True, _prompt and friends +# silently accept whatever default they were given instead of waiting for input. +AUTO_MODE: bool = False + +# Default .env for local dev: examples/jvagent_app/.env (repo root is three +# levels above this file: jvagent/action/sentdm_broadcast/test_cli.py). +_DEFAULT_EXAMPLE_APP_DOTENV = ( + Path(__file__).resolve().parents[3] / "examples" / "jvagent_app" / ".env" +) + + +# --------------------------------------------------------------------------- +# Pretty printing helpers +# --------------------------------------------------------------------------- + + +def _print_header(title: str) -> None: + line = "=" * max(len(title), 32) + print(f"\n{title}\n{line}") + + +def _print_kv(key: str, value: Any) -> None: + print(f" {key:<20s} {value}") + + +def _dump_json(obj: Any) -> None: + try: + print(json.dumps(obj, indent=2, default=str)) + except (TypeError, ValueError): + print(repr(obj)) + + +def _prompt(label: str, default: Optional[str] = None) -> str: + suffix = f" [{default}]" if default else "" + if AUTO_MODE and default is not None: + print(f"{label}{suffix}: {default} (auto)") + return default + while True: + raw = input(f"{label}{suffix}: ").strip() + if raw: + return raw + if default is not None: + return default + print(" (required — please enter a value)") + + +def _prompt_choice(label: str, default: str, options: List[str]) -> str: + opts = "/".join(options) + if AUTO_MODE: + print(f"{label} ({opts}) [{default}]: {default} (auto)") + return default + while True: + raw = input(f"{label} ({opts}) [{default}]: ").strip().lower() + if not raw: + return default + if raw in options: + return raw + print(f" Invalid choice; expected one of {options}.") + + +def _prompt_bool(label: str, default: bool) -> bool: + return _prompt_choice(label, "y" if default else "n", ["y", "n"]) == "y" + + +def _prompt_optional(label: str) -> Optional[str]: + raw = input(f"{label} (blank to skip): ").strip() + return raw or None + + +# --------------------------------------------------------------------------- +# Config cache (base_url + auth method only — never secrets) +# --------------------------------------------------------------------------- + + +def load_cached_defaults() -> Dict[str, Any]: + if not CONFIG_PATH.exists(): + return {} + try: + return json.loads(CONFIG_PATH.read_text("utf-8")) + except (OSError, ValueError): + return {} + + +def save_cached_defaults(data: Dict[str, Any]) -> None: + try: + CONFIG_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8") + except OSError as exc: + print(f"(warning: could not cache defaults: {exc})") + + +# --------------------------------------------------------------------------- +# .env loading — values feed prompt defaults but NEVER get cached to disk. +# --------------------------------------------------------------------------- + + +def load_env_file(explicit_path: Optional[str]) -> Dict[str, str]: + """Load environment variables from a ``.env`` file. + + Resolution order: + 1. ``--env-file`` path if provided (errors if missing). + 2. ``examples/jvagent_app/.env`` under the repository root (path derived + from this script — same file the example app uses). + 3. ``find_dotenv()`` walking up from the current working directory. + 4. ``./.env`` as a final fallback. + + Returns a flat ``{KEY: VALUE}`` dict (empty if nothing is found). + Values found in ``os.environ`` are merged on top so real env vars win. + """ + candidate: Optional[Path] = None + if explicit_path: + candidate = Path(explicit_path).expanduser() + if not candidate.is_file(): + raise SystemExit(f"--env-file not found: {candidate}") + else: + if _DEFAULT_EXAMPLE_APP_DOTENV.is_file(): + candidate = _DEFAULT_EXAMPLE_APP_DOTENV + else: + discovered = find_dotenv(usecwd=True) + if discovered: + candidate = Path(discovered) + elif Path(".env").is_file(): + candidate = Path(".env") + + merged: Dict[str, str] = {} + if candidate: + print(f"Loading defaults from {candidate}") + for key, value in dotenv_values(candidate).items(): + if value is not None: + merged[key] = value + for key in os.environ: + if key.startswith(_PREFIXES): + merged[key] = os.environ[key] + return merged + + +_PREFIXES: Tuple[str, ...] = ("JVAGENT_", "JVSPATIAL_", "SENTDM_") + +# Optional .env defaults for a shorter broadcast flow (see do_send_broadcast). +_SENTDM_ENV_TEST_TO = "SENTDM_TEST_TO" +_SENTDM_ENV_TEST_TEMPLATE_ID = "SENTDM_TEST_TEMPLATE_ID" +_SENTDM_ENV_TEST_PARAMETERS = "SENTDM_TEST_PARAMETERS_JSON" + + +def _env_value(env: Dict[str, str], *keys: str) -> Optional[str]: + for key in keys: + val = env.get(key) + if val and str(val).strip(): + return str(val).strip() + return None + + +def derive_default_base_url(env: Dict[str, str]) -> str: + """Pick the best default base URL from env values. + + Preference: + 1. ``JVAGENT_BASE_URL`` (explicit CLI override; not standard). + 2. ``JVAGENT_PUBLIC_BASE_URL`` (the canonical public origin). + 3. ``http://{JVAGENT_HOST or localhost}:{JVAGENT_PORT or 8000}``. + """ + explicit = _env_value(env, "JVAGENT_BASE_URL", "JVAGENT_PUBLIC_BASE_URL") + if explicit: + return explicit.rstrip("/") + + host = _env_value(env, "JVAGENT_HOST") or "localhost" + if host in ("0.0.0.0", "127.0.0.1"): + host = "localhost" + port_raw = _env_value(env, "JVAGENT_PORT") or "8000" + try: + port = int(port_raw) + except (TypeError, ValueError): + port = 8000 + return f"http://{host}:{port}" + + +# --------------------------------------------------------------------------- +# HTTP client +# --------------------------------------------------------------------------- + + +class JvAgentClient: + """Thin wrapper around the jvagent REST API.""" + + def __init__( + self, + base_url: str, + *, + timeout: float = 30.0, + cli_env: Optional[Dict[str, str]] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self._client = httpx.Client(timeout=timeout) + self._headers: Dict[str, str] = {"accept": "application/json"} + self.cli_env: Dict[str, str] = dict(cli_env or {}) + + def close(self) -> None: + self._client.close() + + # ---- auth helpers ---- + + def login(self, identifier: str, password: str) -> None: + """POST /api/auth/login. ``identifier`` should be an email — jvspatial's + UserLogin model is ``{email: EmailStr, password: str}``. We try a few + payload shapes in case the server has been customized. + """ + url = f"{self.base_url}/api/auth/login" + candidates: List[Tuple[Dict[str, Any], str]] = [ + ({"json": {"email": identifier, "password": password}}, "json/email"), + ( + {"json": {"username": identifier, "password": password}}, + "json/username", + ), + ( + {"data": {"username": identifier, "password": password}}, + "form/username", + ), + ] + last_response: Optional[httpx.Response] = None + for kwargs, label in candidates: + try: + resp = self._client.post(url, **kwargs) + except httpx.HTTPError as exc: + raise RuntimeError(f"Could not reach {url}: {exc}") from exc + last_response = resp + if resp.status_code == 200: + body = _safe_json(resp) + token = _find_token(body) + if not token: + raise RuntimeError( + f"Login {label} succeeded but no access token in response: {body}" + ) + self._headers["authorization"] = f"Bearer {token}" + return + if resp.status_code not in (400, 401, 415, 422): + break + msg = "Login failed" + if last_response is not None: + msg += f" (HTTP {last_response.status_code}): {last_response.text}" + raise RuntimeError(msg) + + def use_api_key(self, api_key: str, *, header: str = "x-api-key") -> None: + self._headers[header.lower()] = api_key + + # ---- generic request ---- + + def request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Any = None, + ) -> Any: + url = f"{self.base_url}{path}" + try: + resp = self._client.request( + method, + url, + params=params, + json=json_body, + headers=self._headers, + ) + except httpx.HTTPError as exc: + raise RuntimeError(f"{method} {path} transport error: {exc}") from exc + body = _safe_json(resp) + if not resp.is_success: + detail = body if isinstance(body, (dict, list)) else resp.text + raise RuntimeError( + f"{method} {path} failed (HTTP {resp.status_code}): {detail}" + ) + return body + + +def _safe_json(resp: httpx.Response) -> Any: + try: + return resp.json() + except ValueError: + return resp.text + + +def _find_token(body: Any) -> Optional[str]: + """Best-effort extract an access token from a login response body.""" + if not isinstance(body, dict): + return None + for key in ("access_token", "token", "jwt"): + val = body.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + data = body.get("data") + if isinstance(data, dict): + return _find_token(data) + return None + + +def _unwrap_data(body: Any) -> Any: + """jvspatial success_response wraps payloads as ``{"data": {...}, "success": true}``.""" + if isinstance(body, dict) and "data" in body and "success" in body: + return body["data"] + return body + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +def list_agents(client: JvAgentClient) -> List[Dict[str, Any]]: + body = client.request("GET", "/api/agents", params={"page": 1, "per_page": 100}) + data = _unwrap_data(body) + if isinstance(data, dict): + agents = data.get("agents") + if isinstance(agents, list): + return agents + if isinstance(body, dict) and isinstance(body.get("agents"), list): + return body["agents"] + if isinstance(body, list): + return body + return [] + + +def list_agent_actions(client: JvAgentClient, agent_id: str) -> List[Dict[str, Any]]: + body = client.request( + "GET", + f"/api/agents/{agent_id}/actions", + params={"page": 1, "per_page": 200}, + ) + data = _unwrap_data(body) + if isinstance(data, dict): + actions = data.get("actions") + if isinstance(actions, list): + return actions + if isinstance(body, dict) and isinstance(body.get("actions"), list): + return body["actions"] + if isinstance(body, list): + return body + return [] + + +def pick_agent(client: JvAgentClient) -> Optional[Dict[str, Any]]: + agents = list_agents(client) + if not agents: + print("No agents found on this server.") + return None + # When in auto-mode, prefer the first agent that actually has a SentDM + # action registered instead of just agents[0]. + if AUTO_MODE: + for agent in agents: + agent_id = str(agent.get("id") or _agent_field(agent, "id") or "") + if not agent_id: + continue + try: + actions = list_agent_actions(client, agent_id) + except RuntimeError: + continue + if find_sentdm_action(actions): + name = agent.get("name") or _agent_field(agent, "name") or "(unnamed)" + print( + f"(auto-selected agent {name} ({agent_id}) " + f"— has SentDMBroadcastAction)" + ) + return agent + # Fall through to listing if none matched. + print("\nAvailable agents:") + for idx, agent in enumerate(agents, start=1): + name = agent.get("name") or _agent_field(agent, "name") or "(unnamed)" + agent_id = agent.get("id") or _agent_field(agent, "id") or "?" + print(f" {idx}) {name} (id={agent_id})") + while True: + raw = _prompt("Pick agent number", "1") + try: + choice = int(raw) + except ValueError: + print(" Please enter a number.") + continue + if 1 <= choice <= len(agents): + return agents[choice - 1] + print(f" Choose between 1 and {len(agents)}.") + + +def _agent_field(agent: Dict[str, Any], key: str) -> Any: + """Look in the top-level dict and in the nested ``context`` block.""" + if key in agent: + return agent[key] + ctx = agent.get("context") + if isinstance(ctx, dict) and key in ctx: + return ctx[key] + return None + + +def find_sentdm_action( + actions: List[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """Locate the SentDMBroadcastAction in a list of action dicts. + + The action registry exposes different fields depending on serializer. + Matches on (in order): + 1. ``archetype`` exactly equals ``SentDMBroadcastAction``. + 2. ``label`` equals the configured action name (``sentdm_broadcast_action`` + or ``jvagent/sentdm_broadcast_action``). + 3. ``id`` starts with ``n.SentDMBroadcastAction.`` — the node id always + embeds the archetype, so this is the most reliable signal. + """ + for action in actions: + archetype = action.get("archetype") or _agent_field(action, "archetype") or "" + if str(archetype).strip() == ACTION_LABEL: + return action + + label = str(action.get("label") or _agent_field(action, "label") or "").strip() + if label in ACTION_NAMES: + return action + + action_id = str(action.get("id") or _agent_field(action, "id") or "").strip() + if action_id.startswith(ACTION_ID_PREFIX): + return action + + return None + + +# --------------------------------------------------------------------------- +# Interactive actions on a chosen SentDMBroadcastAction +# --------------------------------------------------------------------------- + + +def _action_id(action: Dict[str, Any]) -> str: + return str(action.get("id") or _agent_field(action, "id") or "") + + +def do_send_broadcast(client: JvAgentClient, action: Dict[str, Any]) -> None: + """Minimal prompts: to, optional template id, optional parameters JSON, sandbox. + + Set ``SENTDM_TEST_TO``, ``SENTDM_TEST_TEMPLATE_ID``, and/or + ``SENTDM_TEST_PARAMETERS_JSON`` in ``.env`` to pre-fill defaults (fewer keystrokes). + """ + _print_header("Send broadcast") + + env_defaults = client.cli_env + env_to = _env_value(env_defaults, _SENTDM_ENV_TEST_TO) + env_tid = _env_value(env_defaults, _SENTDM_ENV_TEST_TEMPLATE_ID) + env_params = _env_value(env_defaults, _SENTDM_ENV_TEST_PARAMETERS) + + recipients_raw = _prompt( + "recipient phone(s), comma-separated (E.164)", + env_to or "", + ) + recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()] + if not recipients: + print(" No recipients provided; aborting.") + return + + template_id = ( + _prompt( + "template id (UUID, blank = action default_template_*)", env_tid or "" + ).strip() + or None + ) + + params_raw = _prompt( + 'parameters JSON (blank = omit), e.g. {"var_1":"123456"}', + env_params or "", + ).strip() + parameters: Optional[Dict[str, Any]] = None + if params_raw: + try: + parsed = json.loads(params_raw) + except json.JSONDecodeError as exc: + print(f" Could not parse parameters JSON: {exc}; sending without.") + else: + if isinstance(parsed, dict): + parameters = parsed + else: + print(" parameters must be a JSON object; ignoring.") + + sandbox = _prompt_bool("sandbox mode? (no real carrier send)", True) + + body: Dict[str, Any] = {"to": recipients, "sandbox": sandbox} + if template_id: + body["template"] = {"id": template_id} + if parameters: + body["parameters"] = parameters + + print("\nRequest body:") + _dump_json(body) + + resp = client.request( + "POST", + f"/api/actions/{_action_id(action)}/broadcast", + json_body=body, + ) + _print_header("Response") + _dump_json(_unwrap_data(resp)) + + if sandbox: + print( + "\n(Graph) SentDMBroadcastRecord is not created for sandbox sends unless " + "the action has persist_sandbox_sends: true in agent.yaml — " + "otherwise only non-sandbox sends are persisted." + ) + + +def do_reconcile_webhook(client: JvAgentClient, action: Dict[str, Any]) -> None: + _print_header("Reconcile webhook") + body = client.request( + "POST", + f"/api/actions/{_action_id(action)}/webhook/register", + ) + _dump_json(_unwrap_data(body)) + + +def do_show_webhook(client: JvAgentClient, action: Dict[str, Any]) -> None: + _print_header("Webhook URL (currently registered)") + body = client.request( + "GET", + f"/api/actions/{_action_id(action)}/webhook", + ) + _dump_json(_unwrap_data(body)) + + +# --------------------------------------------------------------------------- +# Top-level loop +# --------------------------------------------------------------------------- + + +MENU = """ +SentDM Broadcast Tester +======================= + 1) Send broadcast + 2) Reconcile webhook + 3) Show webhook URL + 4) Pick a different agent / action + 0) Quit +""" + + +def authenticate( + client: JvAgentClient, + cached: Dict[str, Any], + env: Dict[str, str], +) -> str: + env_password = _env_value(env, "JVAGENT_ADMIN_PASSWORD") + env_api_key = _env_value(env, "JVAGENT_API_KEY") + + if not cached.get("auth_method"): + if env_api_key: + default_method = "api_key" + elif env_password: + default_method = "login" + else: + default_method = "login" + else: + default_method = cached["auth_method"] + + method = _prompt_choice("Auth method", default_method, ["login", "api_key", "none"]) + if method == "login": + # jvspatial's /api/auth/login expects an email (EmailStr) + password. + # Prefer JVAGENT_ADMIN_EMAIL; fall back to USERNAME only if it's + # already a valid-looking email. + default_email = ( + cached.get("email") + or _env_value(env, "JVAGENT_ADMIN_EMAIL") + or _env_value(env, "JVAGENT_ADMIN_USERNAME") + or "" + ) + email = _prompt("Admin email", default_email or "admin@jvagent.example") + if "@" not in email: + print( + " (warning: the auth endpoint expects an email — " + "this value will likely 422)" + ) + if env_password: + print(" (using JVAGENT_ADMIN_PASSWORD from .env)") + password = env_password + else: + password = getpass.getpass("Password (input hidden): ") + if not password: + raise RuntimeError( + "Empty password. Set JVAGENT_ADMIN_PASSWORD in your .env or " + "type the password at the prompt." + ) + client.login(email, password) + cached["auth_method"] = "login" + cached["email"] = email + return "login" + if method == "api_key": + default_header = ( + cached.get("api_key_header") + or _env_value(env, "JVAGENT_API_KEY_HEADER") + or "x-api-key" + ) + header = _prompt("API key header", default_header) + if env_api_key: + print(" (using JVAGENT_API_KEY from .env)") + api_key = env_api_key + else: + api_key = getpass.getpass( + "API key (input hidden, or set JVAGENT_API_KEY in .env): " + ) + if not api_key: + raise RuntimeError("An API key is required for api_key auth.") + client.use_api_key(api_key, header=header) + cached["auth_method"] = "api_key" + cached["api_key_header"] = header + return "api_key" + # method == "none": rely on server-side auth being disabled + cached["auth_method"] = "none" + return "none" + + +def select_action( + client: JvAgentClient, +) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: + """Pick an agent and locate its SentDMBroadcastAction.""" + agent = pick_agent(client) + if not agent: + return None + agent_id = str(agent.get("id") or _agent_field(agent, "id") or "") + if not agent_id: + print("Selected agent has no id; aborting.") + return None + + actions = list_agent_actions(client, agent_id) + if not actions: + print(f"Agent {agent_id} has no registered actions.") + return None + + action = find_sentdm_action(actions) + if not action: + print(f"\nNo {ACTION_LABEL} found on this agent. Available actions:") + for a in actions: + print( + f" - {a.get('archetype') or _agent_field(a, 'archetype')}" + f" (label={a.get('label') or _agent_field(a, 'label')}," + f" id={a.get('id') or _agent_field(a, 'id')})" + ) + return None + + _print_header(f"{ACTION_LABEL} found") + _print_kv("agent_id", agent_id) + _print_kv("agent_name", _agent_field(agent, "name")) + _print_kv("action_id", _action_id(action)) + _print_kv("action_label", _agent_field(action, "label")) + _print_kv("enabled", _agent_field(action, "enabled")) + return agent, action + + +def menu_loop(client: JvAgentClient) -> None: + selection = select_action(client) + if not selection: + return + _, action = selection + while True: + print(MENU) + choice = _prompt("choose", "1") + try: + if choice == "1": + do_send_broadcast(client, action) + elif choice == "2": + do_reconcile_webhook(client, action) + elif choice == "3": + do_show_webhook(client, action) + elif choice == "4": + new_sel = select_action(client) + if new_sel: + _, action = new_sel + elif choice == "0": + return + else: + print(f"Unknown choice: {choice}") + except RuntimeError as exc: + print(f"\n[error] {exc}") + except KeyboardInterrupt: + print() + return + + +def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Interactive CLI for the SentDMBroadcastAction. Reads defaults from " + "examples/jvagent_app/.env in this repo by default, or --env-file." + ) + ) + parser.add_argument( + "--env-file", + default=None, + help=( + "Path to a .env file. If omitted, defaults to examples/jvagent_app/.env " + "in this repository (next to the example app); if that file is missing, " + "walks up from the current working directory for a .env." + ), + ) + parser.add_argument( + "--no-env", + action="store_true", + help="Skip .env discovery entirely (still honors real os.environ values).", + ) + parser.add_argument( + "-y", + "--yes", + dest="auto", + action="store_true", + default=None, + help=( + "Auto-mode: accept all defaults without prompting (auto-login when " + "the .env provides credentials, auto-pick the single matching " + "SentDMBroadcastAction, etc.). This is the default whenever the env " + "fully supplies credentials; use --prompt to force interactive auth." + ), + ) + parser.add_argument( + "--prompt", + dest="auto", + action="store_false", + help="Force interactive prompts even when env values are available.", + ) + return parser.parse_args(argv) + + +def main(argv: Optional[List[str]] = None) -> int: + args = parse_args(argv) + if args.no_env: + env = {k: v for k, v in os.environ.items() if k.startswith(_PREFIXES)} + else: + env = load_env_file(args.env_file) + + cached = load_cached_defaults() + + has_password = bool(_env_value(env, "JVAGENT_ADMIN_PASSWORD")) + has_email = bool(_env_value(env, "JVAGENT_ADMIN_EMAIL", "JVAGENT_ADMIN_USERNAME")) + has_api_key = bool(_env_value(env, "JVAGENT_API_KEY")) + env_can_auth = has_api_key or (has_password and has_email) + + global AUTO_MODE + if args.auto is None: + AUTO_MODE = env_can_auth + else: + AUTO_MODE = args.auto + if AUTO_MODE: + print("(auto-mode: accepting defaults; use --prompt to force prompts)") + + default_base = cached.get("base_url") or derive_default_base_url(env) + base_url = _prompt("jvagent base URL", default_base) + + client = JvAgentClient(base_url, cli_env=env) + try: + try: + authenticate(client, cached, env) + except RuntimeError as exc: + print(f"\n[auth error] {exc}") + return 1 + + cached["base_url"] = base_url + save_cached_defaults(cached) + if AUTO_MODE: + print( + "\n(auto-mode complete — interactive menu starts now; " + "press a number to act, 0 to quit)" + ) + AUTO_MODE = False + menu_loop(client) + finally: + client.close() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted.") + sys.exit(130) diff --git a/jvagent/action/sentdm_broadcast/webhook_auth.py b/jvagent/action/sentdm_broadcast/webhook_auth.py new file mode 100644 index 00000000..60c1d90e --- /dev/null +++ b/jvagent/action/sentdm_broadcast/webhook_auth.py @@ -0,0 +1,18 @@ +"""Webhook authentication utilities for the SentDM broadcast action.""" + +from jvagent.action.utils.webhook_system_user import ( + get_or_create_system_user_for_webhook, +) + +SYSTEM_USER_EMAIL = "sentdm-service@system.internal" +_WEBHOOK_PERMISSION = "webhook:sentdm" + + +async def get_or_create_system_user() -> str: + """Get or create system service user for SentDM webhook API keys.""" + return await get_or_create_system_user_for_webhook( + SYSTEM_USER_EMAIL, _WEBHOOK_PERMISSION + ) + + +__all__ = ["get_or_create_system_user", "SYSTEM_USER_EMAIL"] diff --git a/jvagent/action/sentdm_broadcast/webhook_debug.py b/jvagent/action/sentdm_broadcast/webhook_debug.py new file mode 100644 index 00000000..e247c3c0 --- /dev/null +++ b/jvagent/action/sentdm_broadcast/webhook_debug.py @@ -0,0 +1,62 @@ +"""Inbound SentDM webhook request tracing (debug / operations). + +Registers outer HTTP middleware so logs are emitted **before** jvspatial webhook +API-key authentication. That way missing query keys, proxy stripping, invalid +keys, or allowlist rejections still produce a correlatable log line. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from jvspatial.api import Server + +logger = logging.getLogger(__name__) + +_SENTDM_PATH_MARKERS = ("/api/webhook/", "/api/sentdm/webhook/") + + +def register_sentdm_webhook_debug_middleware(server: Server) -> None: + """Attach HTTP middleware that logs safe per-request diagnostics for SentDM webhooks.""" + + @server.middleware("http") + async def sentdm_webhook_request_trace(request, call_next): # type: ignore[no-untyped-def] + path = request.url.path or "" + norm = path.replace("\\", "/") + if not any(m in norm for m in _SENTDM_PATH_MARKERS): + return await call_next(request) + + query = request.query_params + has_api_key_query = bool(query.get("api_key")) + has_x_api_key_header = bool(request.headers.get("x-api-key")) + has_signature = bool(request.headers.get("x-webhook-signature")) + has_webhook_id = bool(request.headers.get("x-webhook-id")) + content_length = request.headers.get("content-length") + client_host = request.client.host if request.client else None + x_forwarded_for = request.headers.get("x-forwarded-for") + + logger.info( + "SentDM webhook inbound (pre jvspatial auth): path=%s " + "api_key_in_query=%s x_api_key_header=%s " + "x_webhook_signature=%s x_webhook_id=%s content_length=%s " + "client_host=%s x_forwarded_for=%s", + path, + has_api_key_query, + has_x_api_key_header, + "present" if has_signature else "absent", + "present" if has_webhook_id else "absent", + content_length, + client_host, + x_forwarded_for, + ) + + response = await call_next(request) + if response.status_code >= 400: + logger.warning( + "SentDM webhook response: path=%s status_code=%s", + path, + response.status_code, + ) + return response diff --git a/jvagent/cli/server_config.py b/jvagent/cli/server_config.py index f2fc0c52..630c9033 100644 --- a/jvagent/cli/server_config.py +++ b/jvagent/cli/server_config.py @@ -10,6 +10,7 @@ CORSConfig, DatabaseConfig, FileStorageConfig, + WebhookConfig, ) from jvspatial.env import env @@ -329,6 +330,16 @@ def create_server_from_config(debug: bool = False, app_root: str = None) -> Serv ) ) + # jvspatial: require HTTPS when api_key is only in query string (mitigates referrer leaks). + # Plain HTTP tunnels (e.g. local forward to http://127.0.0.1:8800) fail unless the + # tunnel sets X-Forwarded-Proto: https or you set JVSPATIAL_WEBHOOK_API_KEY_REQUIRE_HTTPS=false. + webhook_api_key_require_https = get_config_value( + app_config, + "webhook.api_key_require_https", + "JVSPATIAL_WEBHOOK_API_KEY_REQUIRE_HTTPS", + True, + ) + # Create server with grouped configuration server_kwargs = { "title": title, @@ -345,6 +356,9 @@ def create_server_from_config(debug: bool = False, app_root: str = None) -> Serv "debug": debug_mode, "scheduler_enabled": scheduler_enabled, "scheduler_interval": scheduler_interval, + "webhook": WebhookConfig( + webhook_api_key_require_https=webhook_api_key_require_https + ), } server = Server(**server_kwargs) @@ -498,6 +512,12 @@ def create_server_from_config(debug: bool = False, app_root: str = None) -> Serv # endpoints (interact, pageindex, whatsapp, etc.) load via pre_import_action_modules_for_agents. _import_core_endpoint_modules() + from jvagent.action.sentdm_broadcast.webhook_debug import ( + register_sentdm_webhook_debug_middleware, + ) + + register_sentdm_webhook_debug_middleware(server) + return server diff --git a/tests/unit/test_sentdm_webhook_signature.py b/tests/unit/test_sentdm_webhook_signature.py new file mode 100644 index 00000000..c0c8608f --- /dev/null +++ b/tests/unit/test_sentdm_webhook_signature.py @@ -0,0 +1,233 @@ +"""SentDM inbound webhook signature verification (Sent ``v1,`` + legacy hex).""" + +import base64 +import hashlib +import hmac +import json +import time + +from jvagent.action.sentdm_broadcast.endpoints import ( + _normalize_sentdm_webhook_envelope, + _resolve_sentdm_webhook_message_id, + _verify_sentdm_signature, +) +from jvagent.action.sentdm_broadcast.sentdm_broadcast_action import ( + SentDMBroadcastAction, + _extract_sentdm_webhook_list_items, + _sentdm_webhook_url_on_public_origin, + _sentdm_webhook_urls_equivalent, +) + + +def test_sentdm_v1_signature_verifies() -> None: + raw_key = b"unit-test-signing-key" + secret = "whsec_" + base64.b64encode(raw_key).decode("ascii") + webhook_id = "wh_test_endpoint" + ts = str(int(time.time())) + raw_body = b'{"field":"message.status","timestamp":1,"payload":{}}' + signed = f"{webhook_id}.{ts}.{raw_body.decode('utf-8')}" + digest = hmac.new(raw_key, signed.encode("utf-8"), hashlib.sha256).digest() + header = "v1," + base64.b64encode(digest).decode("ascii").rstrip("=") + + assert _verify_sentdm_signature( + secret, + raw_body, + header, + webhook_id=webhook_id, + timestamp=ts, + ) + + +def test_derive_status_from_message_status() -> None: + st, err = SentDMBroadcastAction._derive_status_and_error( + "message", + {"message_id": "x", "message_status": "READ"}, + ) + assert st == "read" + assert err is None + + +def test_normalize_dashboard_wrapped_envelope() -> None: + body = { + "eventType": "message.read", + "eventData": { + "field": "message", + "sub_type": "message.read", + "timestamp": "2026-05-14T14:30:28Z", + "payload": { + "message_id": "mid-1", + "message_status": "READ", + "channel": "sms", + }, + }, + } + field, fold = _normalize_sentdm_webhook_envelope(body) + assert field == "message" + assert fold.get("message_id") == "mid-1" + assert fold.get("sub_type") == "message.read" + st, _ = SentDMBroadcastAction._derive_status_and_error(field, fold) + assert st == "read" + + +def test_normalize_message_queued_dashboard_shape() -> None: + body = { + "eventType": "message.queued", + "eventData": { + "field": "message", + "payload": { + "channel": "unknown", + "account_id": "372f629c-194d-4c88-8cb7-582ead4bcdf0", + "message_id": "f06ff5f1-e05d-469d-baa4-5b0e32a24cf2", + "template_id": "f70c78f8-4be0-49eb-88e2-cd7aa9a7cef9", + "inbound_number": "unknown", + "message_status": "QUEUED", + "outbound_number": "unknown", + }, + "sub_type": "message.queued", + "timestamp": "2026-05-14T18:49:14Z", + }, + } + field, fold = _normalize_sentdm_webhook_envelope(body) + assert field == "message" + assert fold["message_id"] == "f06ff5f1-e05d-469d-baa4-5b0e32a24cf2" + st, _ = SentDMBroadcastAction._derive_status_and_error(field, fold) + assert st == "queued" + + +def test_resolve_message_id_from_response_body_sentdm_alias() -> None: + mid = "f06ff5f1-e05d-469d-baa4-5b0e32a24cf2" + payload = { + "responseBody": json.dumps( + { + "status": "received", + "sentdm_message_id": mid, + "record_id": None, + } + ), + } + assert _resolve_sentdm_webhook_message_id(payload, {}) == mid + + +def test_derive_status_message_status_queued_uppercase() -> None: + st, err = SentDMBroadcastAction._derive_status_and_error( + "message", + {"message_id": "x", "message_status": "QUEUED", "sub_type": "message.queued"}, + ) + assert st == "queued" + assert err is None + + +def test_sentdm_legacy_hex_still_verifies() -> None: + secret = "plain-test-secret" + raw_body = b'{"x":1}' + sig = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + assert _verify_sentdm_signature( + secret, + raw_body, + sig, + webhook_id="ignored", + timestamp="ignored", + ) + + +def test_sentdm_webhook_path_prefix_for_base() -> None: + assert ( + SentDMBroadcastAction._sentdm_webhook_path_prefix_for_base("https://ex.com") + == "https://ex.com/api/webhook/" + ) + assert ( + SentDMBroadcastAction._sentdm_webhook_path_prefix_for_base("https://ex.com/") + == "https://ex.com/api/webhook/" + ) + + +def test_extract_sentdm_webhook_list_nested_data() -> None: + body = { + "data": { + "webhooks": [ + {"id": "w1", "endpoint_url": "https://h/api/webhook/a?api_key=x"} + ] + } + } + rows = _extract_sentdm_webhook_list_items(body) + assert len(rows) == 1 + assert rows[0]["id"] == "w1" + + +def test_sentdm_webhook_urls_equivalent_ignores_query() -> None: + a = "https://h/api/webhook/n.Action.1?api_key=jv_abc" + b = "https://h/api/webhook/n.Action.1" + assert _sentdm_webhook_urls_equivalent(a, b) + assert _sentdm_webhook_url_on_public_origin(a, "https://h") + + +def test_sentdm_legacy_webhook_path_still_on_public_origin() -> None: + legacy = "https://h/api/sentdm/webhook/n.Action.1" + assert _sentdm_webhook_url_on_public_origin(legacy, "https://h") + + +def test_sentdm_webhook_different_paths_not_equivalent() -> None: + a = "https://h/api/webhook/n.Action.1?k=1" + b = "https://h/api/webhook/n.Action.2?k=2" + assert not _sentdm_webhook_urls_equivalent(a, b) + + +def test_extract_message_descriptors_v3_success_envelope() -> None: + envelope = { + "success": True, + "data": { + "messages": [ + { + "id": "m1", + "to": "+15550001", + "channel": "sms", + "status": "queued", + }, + ] + }, + "meta": {}, + } + rows = SentDMBroadcastAction._extract_sent_message_descriptors(envelope) + assert len(rows) == 1 + assert rows[0]["id"] == "m1" + assert rows[0]["to"] == "+15550001" + + +def test_extract_message_descriptors_v3_single_message_in_data() -> None: + envelope = { + "success": True, + "data": {"id": "solo-1", "message_status": "QUEUED"}, + "meta": {}, + } + rows = SentDMBroadcastAction._extract_sent_message_descriptors(envelope) + assert len(rows) == 1 + assert rows[0]["id"] == "solo-1" + + +def test_extract_message_descriptors_nested_results_messages() -> None: + envelope = { + "success": True, + "data": { + "results": { + "messages": [ + {"id": "mid-nested", "to": "+15550001", "channel": "sms"}, + ] + } + }, + "meta": {}, + } + rows = SentDMBroadcastAction._extract_sent_message_descriptors(envelope) + assert len(rows) == 1 + assert rows[0]["id"] == "mid-nested" + + +def test_extract_message_descriptors_message_id_uuid_only_deep() -> None: + mid = "f06ff5f1-e05d-469d-baa4-5b0e32a24cf2" + envelope = { + "success": True, + "data": {"payload": {"row": {"message_id": mid}}}, + "meta": {}, + } + rows = SentDMBroadcastAction._extract_sent_message_descriptors(envelope) + assert len(rows) == 1 + assert rows[0]["message_id"] == mid