================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/account/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint.txt TITLE: Get authenticated account ================================================================================ URL: https://docs.sent.dm/llms/reference/api/account/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint.txt # GET /v3/me Get authenticated account Returns the account associated with the provided API key. The response includes account identity, contact information, messaging channel configuration, and — depending on the account type — either a list of child profiles or the profile's own settings. **Account types:** - `organization` — Has child profiles. The `profiles` array is populated. - `user` — Standalone account with no profiles. - `profile` — Child of an organization. Includes `organization_id`, `short_name`, `status`, and `settings`. **Channels:** The `channels` object always includes `sms`, `whatsapp`, and `rcs`. Each channel has a `configured` boolean. Configured channels expose additional details such as `phone_number`. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint` **Tags:** Accounts ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Account retrieved successfully. Response shape varies by account type (organization, user, or profile). #### application/json ```typescript { success?: boolean, data?: { type?: string, id?: string, organization_id?: string, name?: string, short_name?: string, email?: string, icon?: string, description?: string, created_at?: string, channels?: unknown, status?: string, settings?: { allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string }, profiles?: Array<{ id?: string, name?: string, icon?: string, description?: string, short_name?: string, role?: string, status?: string, created_at?: string, settings?: unknown }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "type": "profile", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "organization_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "name": "Marketing", "short_name": "MKT", "email": "marketing@acme.com", "icon": "https://cdn.sent.dm/icons/marketing.png", "description": "Marketing department sender profile", "created_at": "2025-01-20T14:00:00+00:00", "channels": { "sms": { "configured": true, "phone_number": "+14155550100" }, "whatsapp": { "configured": true, "phone_number": "+14155550100", "business_name": "Acme Corporation" }, "rcs": { "configured": false, "phone_number": "+14155550100" } }, "status": "approved", "settings": { "allow_contact_sharing": true, "allow_template_sharing": true, "inherit_contacts": true, "inherit_templates": false, "inherit_tcr_brand": true, "inherit_tcr_campaign": false, "billing_model": "organization" }, "profiles": [] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2460263+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API key #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_001", "message": "Invalid or missing API key. Ensure the x-api-key header is set with a valid key.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2460405+00:00", "version": "v3" } } ``` ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving account information. Please try again later.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2460418+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/accounts/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint.txt TITLE: Get authenticated account ================================================================================ URL: https://docs.sent.dm/llms/reference/api/accounts/SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint.txt # GET /v3/me Get authenticated account Returns the account associated with the provided API key. The response includes account identity, contact information, messaging channel configuration, and — depending on the account type — either a list of child profiles or the profile's own settings. **Account types:** - `organization` — Has child profiles. The `profiles` array is populated. - `user` — Standalone account with no profiles. - `profile` — Child of an organization. Includes `organization_id`, `short_name`, `status`, and `settings`. **Channels:** The `channels` object always includes `sms`, `whatsapp`, and `rcs`. Each channel has a `configured` boolean. Configured channels expose additional details such as `phone_number`. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3AccountGetAccountEndpoint` **Tags:** Accounts ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Account retrieved successfully. Response shape varies by account type (organization, user, or profile). #### application/json ```typescript { success?: boolean, data?: { type?: string, id?: string, organization_id?: string, name?: string, short_name?: string, email?: string, icon?: string, description?: string, created_at?: string, channels?: unknown, status?: string, settings?: { allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string }, profiles?: Array<{ id?: string, name?: string, icon?: string, description?: string, short_name?: string, role?: string, status?: string, created_at?: string, settings?: unknown }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "type": "profile", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "organization_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "name": "Marketing", "short_name": "MKT", "email": "marketing@acme.com", "icon": "https://cdn.sent.dm/icons/marketing.png", "description": "Marketing department sender profile", "created_at": "2025-01-20T14:00:00+00:00", "channels": { "sms": { "configured": true, "phone_number": "+14155550100" }, "whatsapp": { "configured": true, "phone_number": "+14155550100", "business_name": "Acme Corporation" }, "rcs": { "configured": false, "phone_number": "+14155550100" } }, "status": "approved", "settings": { "allow_contact_sharing": true, "allow_template_sharing": true, "inherit_contacts": true, "inherit_templates": false, "inherit_tcr_brand": true, "inherit_tcr_campaign": false, "billing_model": "organization" }, "profiles": [] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2460263+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API key #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_001", "message": "Invalid or missing API key. Ensure the x-api-key header is set with a valid key.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2460405+00:00", "version": "v3" } } ``` ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving account information. Please try again later.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2460418+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/authentication.txt TITLE: Authentication ================================================================================ URL: https://docs.sent.dm/llms/reference/api/authentication.txt Complete guide to API authentication, security best practices, and credential management for the Sent API v3 # Authentication Guide **Secure, simple, and scalable** – The Sent API v3 uses header-based authentication with a single API key that identifies your account and authorizes all requests. --- ## Authentication Overview The Sent API v3 uses **header-based authentication** with one required header: ```http x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` Your account is automatically identified from the API key. No additional sender ID or customer identifier is required. **🚀 Getting Started:** Need API credentials? Visit your [Sent Dashboard](https://app.sent.dm/dashboard/api-keys) to generate your keys instantly. --- ## Getting Your Credentials ### Step 1: Access Your Dashboard 1. Log into your [Sent Dashboard](https://app.sent.dm) 2. Navigate to **Settings** → **API Keys** 3. Click **Generate New API Key** ### Step 2: Understand Key Types | Key Type | Prefix | Purpose | |----------|--------|---------| | Production | UUID format | For live, production environments | | Test/Sandbox | UUID format | For development and testing | ### Step 3: Environment Setup Store your credentials securely using environment variables: ```bash # .env file - Production SENT_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # .env file - Development SENT_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ``` > **Security Note:** Never commit your `.env` file to version control. Add it to `.gitignore` immediately. --- ## Authentication Method ### Header-Based Authentication All Sent API endpoints require the `x-api-key` header with every request: | Header | Type | Purpose | Example | |--------|------|---------|---------| | `x-api-key` | String | Your secret API key for authentication | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | ### Example Request ```bash curl -X GET https://api.sent.dm/v3/contacts \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -H "Content-Type: application/json" ``` ### Why Header-Based? - **🔒 Secure**: Credentials are sent in headers, not in the URL or body - **🚀 Simple**: No complex OAuth flows or token exchanges required - **⚡ Fast**: Direct authentication on every request with minimal overhead - **🔄 Stateless**: No session management or token refresh required --- ## Common Response Headers All responses include these headers for debugging and monitoring: | Header | Description | |--------|-------------| | `X-Request-Id` | Unique request identifier for support and debugging | | `X-Response-Time` | Server processing time (e.g., `12ms`) | | `X-API-Version` | API version (always `v3`) | --- ## Common Authentication Errors ### Missing API Key (401) ```json { "success": false, "error": { "code": "AUTH_001", "message": "User is not authenticated", "doc_url": "https://docs.sent.dm/errors/AUTH_001" }, "meta": { "request_id": "req_xxx", "timestamp": "2024-01-01T00:00:00Z", "version": "v3" } } ``` **Solution**: Ensure the `x-api-key` header is included in all requests. ### Invalid or Missing API Key (401) ```json { "success": false, "error": { "code": "AUTH_002", "message": "Invalid or missing API key", "doc_url": "https://docs.sent.dm/errors/AUTH_002" }, "meta": { ... } } ``` **Solution**: - Verify your API key is correct and the `x-api-key` header value is not empty - Check if the key has been revoked - Generate a new key from the dashboard if needed ### Onboarding Not Complete (403) Depending on where your account is in the onboarding flow, you may receive one of three 403 responses: ```json { "success": false, "error": { "code": "AUTH_006", "message": "Your KYC verification is not complete. Please submit your KYC documents before using the API.", "doc_url": "https://docs.sent.dm/errors/AUTH_006" }, "meta": { ... } } ``` ```json { "success": false, "error": { "code": "AUTH_007", "message": "Your channel setup is not complete. Please configure at least one messaging channel before using the API.", "doc_url": "https://docs.sent.dm/errors/AUTH_007" }, "meta": { ... } } ``` ```json { "success": false, "error": { "code": "AUTH_005", "message": "Your account is not yet activated. Please wait for account activation before using the API.", "doc_url": "https://docs.sent.dm/errors/AUTH_005" }, "meta": { ... } } ``` **Solution**: Complete the step indicated by the error code — KYC (AUTH_006) → channel setup (AUTH_007) → wait for activation (AUTH_005). See [Account Setup](/start/quickstart/account-setup) for the full flow. ### Insufficient Permissions (403) ```json { "success": false, "error": { "code": "AUTH_004", "message": "Insufficient permissions for this operation", "doc_url": "https://docs.sent.dm/errors/AUTH_004" }, "meta": { ... } } ``` **Solution**: Verify the API key has the required permissions for the operation. --- ## Debugging Authentication Issues 1. **Verify API Key Format** - API keys are UUID format (e.g., `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) - Both production and test keys use the same format - Key should be complete (no truncation) 2. **Check Environment Variables** ```bash echo $SENT_API_KEY ``` 3. **Test with cURL** ```bash curl -v -X GET https://api.sent.dm/v3/me \ -H "x-api-key: $SENT_API_KEY" ``` 4. **Verify Dashboard Settings** - Check if API key is active in your dashboard - Confirm key hasn't been disabled or rotated --- ## Security Best Practices ### Do's - **✅ Store in Environment Variables**: Never hardcode credentials in source code - **✅ Use Different Keys**: Separate keys for development, staging, and production - **✅ Rotate Regularly**: Update API keys every 90 days or after security incidents - **✅ Monitor Usage**: Track API key usage patterns and set up alerts - **✅ Restrict Access**: Limit who has access to production API keys - **✅ Use HTTPS**: Always use `https://api.sent.dm` for secure requests ### Don'ts - **❌ Never Commit to Version Control**: Add `.env` to `.gitignore` - **❌ Avoid Client-Side Exposure**: Never send API keys to browsers or mobile apps - **❌ Don't Share Keys**: Each environment and team member should have unique keys - **❌ Avoid Logging**: Don't log API keys in application logs --- ## Rate Limiting Authentication works seamlessly with our rate limiting system: - **Standard endpoints**: 200 requests/minute - **Sensitive endpoints** (e.g., secret rotation): 10 requests/minute - Rate limit headers included on `429` responses: - `Retry-After`: Seconds until you can retry - `X-RateLimit-Limit`: Maximum requests allowed - `X-RateLimit-Remaining`: Requests remaining - `X-RateLimit-Reset`: Unix timestamp when the window resets 📚 **Learn More**: See our [Rate Limits Documentation](/reference/api/rate-limits) for detailed information. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint.txt TITLE: Create a campaign for a profile's brand ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint.txt # POST /v3/profiles/{profileId}/campaigns Create a campaign for a profile's brand Creates a new campaign scoped under the brand of the specified profile. Each campaign must include at least one use case with sample messages. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsCreateBrandCampaignEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 201 Campaign created successfully #### application/json ```typescript { success?: boolean, data?: { }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "useCases": [ { "campaignId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ], "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "createdAt": "0001-01-01T00:00:00+00:00", "updatedAt": null } ], "tcrSyncError": null, "kycSubmissionFormId": null, "type": "App", "customerId": "00000000-0000-0000-0000-000000000000", "name": "Customer Notifications", "description": "Appointment reminders and account notifications", "submittedToTCR": false, "submittedAt": null, "billedDate": null, "cost": null, "telnyxCampaignId": null, "tcrCampaignId": null, "cspId": null, "status": null, "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "resellerId": null, "messageFlow": "User signs up on website and opts in to receive SMS notifications", "privacyPolicyLink": null, "termsAndConditionsLink": null, "optinMessage": null, "optoutMessage": null, "helpMessage": null, "optinKeywords": null, "optoutKeywords": null, "helpKeywords": null, "upstreamCnpId": null, "sharingStatus": null, "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "createdAt": "2026-04-08T15:54:52.222036+00:00", "updatedAt": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2222791+00:00", "version": "v3" } } ``` ### 400 Invalid request - validation errors or inherit_tcr_campaign=true #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Cannot create campaigns when inherit_tcr_campaign=true. Set inherit_tcr_campaign=false to create your own campaigns.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2222879+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile or brand not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_009", "message": "Brand not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2222887+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to create campaign. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2222897+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint.txt TITLE: Delete a campaign ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint.txt # DELETE /v3/profiles/{profileId}/campaigns/{campaignId} Delete a campaign Deletes a campaign by ID from the brand of the specified profile. The profile must belong to the authenticated organization. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsDeleteBrandCampaignEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route parameter | | `campaignId` | `string` | true | Campaign ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 204 Campaign deleted successfully ### 400 Invalid profile or campaign ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid profile or campaign ID format", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2281619+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile, brand, or campaign not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_010", "message": "Campaign not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2281646+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to delete campaign. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2281654+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint.txt TITLE: Get campaigns for a profile's brand ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint.txt # GET /v3/profiles/{profileId}/campaigns Get campaigns for a profile's brand Retrieves all campaigns linked to the profile's brand, including use cases and sample messages. Returns inherited campaigns if inherit_tcr_campaign=true. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsGetBrandCampaignsEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Campaigns retrieved successfully #### application/json ```typescript { success?: boolean, data?: Array, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": [ { "useCases": [ { "campaignId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ], "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "createdAt": "0001-01-01T00:00:00+00:00", "updatedAt": null } ], "tcrSyncError": null, "kycSubmissionFormId": null, "type": "App", "customerId": "00000000-0000-0000-0000-000000000000", "name": "Customer Notifications", "description": "Appointment reminders and account notifications", "submittedToTCR": false, "submittedAt": null, "billedDate": null, "cost": null, "telnyxCampaignId": null, "tcrCampaignId": null, "cspId": null, "status": null, "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "resellerId": null, "messageFlow": "User signs up on website and opts in to receive SMS notifications", "privacyPolicyLink": null, "termsAndConditionsLink": null, "optinMessage": null, "optoutMessage": null, "helpMessage": null, "optinKeywords": null, "optoutKeywords": null, "helpKeywords": null, "upstreamCnpId": null, "sharingStatus": null, "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "createdAt": "2026-04-08T15:54:52.2329579+00:00", "updatedAt": null } ], "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2329604+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile or brand not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_009", "message": "Brand not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2329651+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to retrieve campaigns. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2329672+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint.txt TITLE: Update a campaign ================================================================================ URL: https://docs.sent.dm/llms/reference/api/brands/SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint.txt # PUT /v3/profiles/{profileId}/campaigns/{campaignId} Update a campaign Updates an existing campaign under the brand of the specified profile. Cannot update campaigns that have already been submitted to TCR. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3BrandsCampaignsUpdateBrandCampaignEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | | `campaignId` | `string` | true | Campaign ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Campaign updated successfully #### application/json ```typescript { success?: boolean, data?: { }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "useCases": [ { "campaignId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "customerId": "00000000-0000-0000-0000-000000000000", "messagingUseCaseUs": "ACCOUNT_NOTIFICATION", "sampleMessages": [ "Hi {name}, your appointment is confirmed for {date} at {time}.", "Your order #{order_id} has been shipped. Track at {url}" ], "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "createdAt": "0001-01-01T00:00:00+00:00", "updatedAt": null } ], "tcrSyncError": null, "kycSubmissionFormId": null, "type": "App", "customerId": "00000000-0000-0000-0000-000000000000", "name": "Customer Notifications Updated", "description": "Updated appointment reminders and account notifications", "submittedToTCR": false, "submittedAt": null, "billedDate": null, "cost": null, "telnyxCampaignId": null, "tcrCampaignId": null, "cspId": null, "status": null, "brandId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "resellerId": null, "messageFlow": null, "privacyPolicyLink": null, "termsAndConditionsLink": null, "optinMessage": null, "optoutMessage": null, "helpMessage": null, "optinKeywords": null, "optoutKeywords": null, "helpKeywords": null, "upstreamCnpId": null, "sharingStatus": null, "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "createdAt": "2026-04-01T15:54:52.2397922+00:00", "updatedAt": "2026-04-08T15:54:52.2397955+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2398293+00:00", "version": "v3" } } ``` ### 400 Invalid request - validation errors or inherit_tcr_campaign=true #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "These campaigns are read-only. Set inherit_tcr_campaign to false to manage your own campaigns.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2398347+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Valid API key required ### 403 Forbidden ### 404 Profile, brand, or campaign not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_010", "message": "Campaign not found for this profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.239835+00:00", "version": "v3" } } ``` ### 500 Internal server error occurred #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to update campaign. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.2398355+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint.txt TITLE: Create a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint.txt # POST /v3/contacts Create a contact Creates a new contact by phone number and associates it with the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsCreateContactEndpoint` **Tags:** Contacts ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 201 Contact created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms", "default_channel": "sms", "opt_out": false, "is_inherited": false, "created_at": "2026-04-08T15:54:52.1394685+00:00", "updated_at": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1396168+00:00", "version": "v3" } } ``` ### 400 Invalid request - phone number missing or invalid #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Phone number is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1396241+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 409 Contact already exists for this customer #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_007", "message": "Contact with this phone number already exists for this customer", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1396254+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1396268+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint.txt TITLE: Delete a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint.txt # DELETE /v3/contacts/{id} Delete a contact Dissociates a contact from the authenticated customer. Inherited contacts cannot be deleted. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsDeleteContactEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Contact ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 204 Contact deleted successfully ### 400 Invalid contact ID or read-only contact #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid contact ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1446047+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Contact not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1446078+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1446087+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint.txt TITLE: Get contact by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint.txt # GET /v3/contacts/{id} Get contact by ID Retrieves a specific contact by their unique identifier. Returns detailed contact information including phone formats, available channels, and opt-out status. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsGetContactByIdEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Contact ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Contact found and returned successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "sms", "opt_out": false, "is_inherited": false, "created_at": "2026-04-08T15:54:52.1504529+00:00", "updated_at": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1504536+00:00", "version": "v3" } } ``` ### 400 Invalid request - ContactId is empty or invalid format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_003", "message": "Invalid ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1504577+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API credentials ### 403 Forbidden ### 404 Contact not found for the authenticated customer #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1504586+00:00", "version": "v3" } } ``` ### 500 Internal server error - Contact support with request ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1504594+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint.txt TITLE: Get contacts list ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint.txt # GET /v3/contacts Get contacts list Retrieves a paginated list of contacts for the authenticated customer. Supports filtering by search term, channel, or phone number. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsGetContactsEndpoint` **Tags:** Contacts ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | Page number (1-indexed) | | `page_size` | `integer` | true | Number of items per page | | `search` | `string` | false | Optional search term for filtering contacts | | `channel` | `string` | false | Optional channel filter (sms, whatsapp) | | `phone` | `string` | false | Optional phone number filter (alternative to list view) | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Contacts retrieved successfully #### application/json ```typescript { success?: boolean, data?: { contacts?: Array<{ id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }>, pagination?: unknown }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "contacts": [ { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "sms", "opt_out": false, "is_inherited": false, "created_at": "2026-04-08T15:54:52.1546567+00:00", "updated_at": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 150, "total_pages": 8, "has_more": true, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.154714+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_005", "message": "Page must be greater than 0", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1547179+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Contact not found (when filtering by phone) #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found with the specified phone number", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1547187+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1547194+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint.txt TITLE: Update a contact ================================================================================ URL: https://docs.sent.dm/llms/reference/api/contacts/SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint.txt # PATCH /v3/contacts/{id} Update a contact Updates a contact's default channel and/or opt-out status. Inherited contacts cannot be updated. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ContactsUpdateContactEndpoint` **Tags:** Contacts ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Contact ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Contact updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, phone_number?: string, format_e164?: string, format_international?: string, format_national?: string, format_rfc?: string, country_code?: string, region_code?: string, available_channels?: string, default_channel?: string, opt_out?: boolean, is_inherited?: boolean, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "phone_number": "+1234567890", "format_e164": "+1234567890", "format_international": "+1 234-567-890", "format_national": "(234) 567-890", "format_rfc": "tel:+1-234-567-890", "country_code": "1", "region_code": "US", "available_channels": "sms,whatsapp", "default_channel": "whatsapp", "opt_out": false, "is_inherited": false, "created_at": "2026-03-09T15:54:52.1592474+00:00", "updated_at": "2026-04-08T15:54:52.1592499+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1592511+00:00", "version": "v3" } } ``` ### 400 Invalid request - read-only contact or invalid channel #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "This contact is read-only and cannot be updated.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1592554+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Contact not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1592562+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1592569+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/data-models.txt TITLE: Data Models ================================================================================ URL: https://docs.sent.dm/llms/reference/api/data-models.txt Complete reference for all data structures, schemas, and types used in the Sent API v3 # Data Models Complete reference for all data structures used in the Sent API v3. All API responses follow a consistent JSON envelope format with standardized property naming conventions. **Naming Convention:** The API v3 uses `snake_case` for all JSON property names (e.g., `phone_number`, `created_at`). --- ## Response Envelope All API responses follow a consistent envelope structure: ### ApiResponse<T> | Field | Type | Description | |-------|------|-------------| | `success` | boolean | Indicates whether the request was successful | | `data` | T \| null | The response data (null if error) | | `error` | [ApiError](#apierror) \| null | Error details (null if successful) | | `meta` | [ApiMeta](#apimeta) | Metadata about the request and response | ### Example Success Response ```json { "success": true, "data": { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "phone_number": "+1234567890", "created_at": "2024-01-15T10:30:00Z" }, "error": null, "meta": { "request_id": "req_abc123", "timestamp": "2024-01-15T10:30:00Z", "version": "v3", "response_time_ms": 45 } } ``` ### Example Error Response ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/errors/RESOURCE_001" }, "meta": { "request_id": "req_def456", "timestamp": "2024-01-15T10:30:00Z", "version": "v3" } } ``` ### Type Definitions ```typescript export interface ApiResponse { success: boolean; data: T | null; error: ApiError | null; meta: ApiMeta; } export interface ApiError { code: string; message: string; details: Record | null; doc_url: string | null; } export interface ApiMeta { request_id: string; timestamp: string; version: string; response_time_ms?: number; } export interface PaginationMeta { has_next_page: boolean; has_previous_page: boolean; next_cursor: string | null; previous_cursor: string | null; total_count?: number; } ``` ```python from typing import Generic, TypeVar, Optional, Dict, List, Any from dataclasses import dataclass T = TypeVar('T') @dataclass class ApiResponse(Generic[T]): success: bool data: Optional[T] error: Optional['ApiError'] meta: 'ApiMeta' @dataclass class ApiError: code: str message: str details: Optional[Dict[str, List[str]]] doc_url: Optional[str] @dataclass class ApiMeta: request_id: str timestamp: str version: str response_time_ms: Optional[int] = None @dataclass class PaginationMeta: has_next_page: bool has_previous_page: bool next_cursor: Optional[str] previous_cursor: Optional[str] total_count: Optional[int] = None ``` ```go package sent // ApiResponse represents the standard API response envelope type ApiResponse[T any] struct { Success bool `json:"success"` Data T `json:"data"` Error *ApiError `json:"error"` Meta ApiMeta `json:"meta"` } // ApiError represents error details in API responses type ApiError struct { Code string `json:"code"` Message string `json:"message"` Details map[string][]string `json:"details"` DocURL string `json:"doc_url"` } // ApiMeta contains metadata about the API request/response type ApiMeta struct { RequestID string `json:"request_id"` Timestamp string `json:"timestamp"` Version string `json:"version"` ResponseTimeMs int `json:"response_time_ms,omitempty"` } // PaginationMeta contains pagination information for list responses type PaginationMeta struct { HasNextPage bool `json:"has_next_page"` HasPreviousPage bool `json:"has_previous_page"` NextCursor string `json:"next_cursor"` PreviousCursor string `json:"previous_cursor"` TotalCount int `json:"total_count,omitempty"` } ``` --- ## Contact Models ### ContactResponse A contact represents a phone number with validated formats and available messaging channels. | Field | Type | Description | |-------|------|-------------| | `id` | string (uuid) | Unique identifier for the contact | | `phone_number` | string | Phone number in original format | | `format_e164` | string | Phone number in E.164 format (e.g., `+1234567890`) | | `format_international` | string | International format (e.g., `+1 234-567-890`) | | `format_national` | string | National format (e.g., `(234) 567-890`) | | `format_rfc` | string | RFC 3966 format (e.g., `tel:+1-234-567-890`) | | `country_code` | string | Country calling code (e.g., `1` for US/Canada) | | `region_code` | string | ISO 3166-1 alpha-2 country code (e.g., `US`, `CA`) | | `available_channels` | string | Comma-separated list (e.g., `sms,whatsapp`) | | `default_channel` | string | Default messaging channel (`sms` or `whatsapp`) | | `opt_out` | boolean | Whether the contact has opted out of messaging | | `created_at` | string (date-time) | When the contact was created | | `updated_at` | string (date-time) | When the contact was last updated | ### Type Definitions ```typescript export interface Contact { id: string; phone_number: string; format_e164: string; format_international: string; format_national: string; format_rfc: string; country_code: string; region_code: string; available_channels: string; default_channel: string; opt_out: boolean; created_at: string; updated_at: string; } export interface CreateContactRequest { phone_number: string; sandbox?: boolean; } export interface UpdateContactRequest { phone_number?: string; sandbox?: boolean; } ``` ```python from dataclasses import dataclass from typing import Optional @dataclass class Contact: id: str phone_number: str format_e164: str format_international: str format_national: str format_rfc: str country_code: str region_code: str available_channels: str default_channel: str opt_out: bool created_at: str updated_at: str @dataclass class CreateContactRequest: phone_number: str sandbox: Optional[bool] = None @dataclass class UpdateContactRequest: phone_number: Optional[str] = None sandbox: Optional[bool] = None ``` ```go // Contact represents a phone number with validated formats and available messaging channels type Contact struct { ID string `json:"id"` PhoneNumber string `json:"phone_number"` FormatE164 string `json:"format_e164"` FormatInternational string `json:"format_international"` FormatNational string `json:"format_national"` FormatRFC string `json:"format_rfc"` CountryCode string `json:"country_code"` RegionCode string `json:"region_code"` AvailableChannels string `json:"available_channels"` DefaultChannel string `json:"default_channel"` OptOut bool `json:"opt_out"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // CreateContactRequest represents the request body for creating a contact type CreateContactRequest struct { PhoneNumber string `json:"phone_number"` Sandbox bool `json:"sandbox,omitempty"` } // UpdateContactRequest represents the request body for updating a contact type UpdateContactRequest struct { PhoneNumber string `json:"phone_number,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } ``` --- ## Message Models ### MessageResponse A message represents an outbound message sent to a contact. | Field | Type | Description | |-------|------|-------------| | `id` | string (uuid) | Unique message identifier | | `customer_id` | string (uuid) | Customer who sent the message | | `contact_id` | string (uuid) | Contact who received the message | | `phone` | string | Recipient phone number | | `phone_international` | string | International format phone number | | `region_code` | string | Country code (e.g., `US`) | | `template_id` | string (uuid) \| null | Template used (if any) | | `template_name` | string | Name of the template used | | `template_category` | string | Category of the template | | `channel` | string | Channel used (`sms`, `whatsapp`) | | `message_body` | MessageBody \| null | Rendered message content | | `status` | string | Message status: `QUEUED`, `ACCEPTED`, `SENT`, `DELIVERED`, `READ`, `FAILED` | | `created_at` | string (date-time) | When the message was created | | `price` | number \| null | Price charged for the message | | `events` | MessageEvent[] \| null | Delivery events | ### Type Definitions ```typescript export interface Message { id: string; customer_id: string; contact_id: string; phone: string; phone_international: string; region_code: string; template_id: string | null; template_name: string; template_category: string; channel: string; message_body: MessageBody | null; status: MessageStatus; created_at: string; price: number | null; events: MessageEvent[] | null; } export interface MessageBody { body: string; header?: MessageHeader; footer?: MessageFooter; buttons?: MessageButton[]; } export interface MessageHeader { type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; content: string; } export interface MessageFooter { content: string; } export interface MessageButton { type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER'; text: string; url?: string; phone_number?: string; } export interface MessageEvent { status: string; timestamp: string; provider: string; error_code?: string; error_message?: string; } export interface SendMessageRequest { to: string[]; template: { id?: string; name?: string; parameters?: Record; }; channel?: string[]; sandbox?: boolean; } export type MessageStatus = | 'QUEUED' | 'ACCEPTED' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'; ``` ```python from dataclasses import dataclass from typing import Optional, List, Dict, Literal @dataclass class Message: id: str customer_id: str contact_id: str phone: str phone_international: str region_code: str template_id: Optional[str] template_name: str template_category: str channel: str message_body: Optional['MessageBody'] status: str created_at: str price: Optional[float] events: Optional[List['MessageEvent']] @dataclass class MessageBody: body: str header: Optional['MessageHeader'] = None footer: Optional['MessageFooter'] = None buttons: Optional[List['MessageButton']] = None @dataclass class MessageHeader: type: Literal['TEXT', 'IMAGE', 'VIDEO', 'DOCUMENT'] content: str @dataclass class MessageFooter: content: str @dataclass class MessageButton: type: Literal['QUICK_REPLY', 'URL', 'PHONE_NUMBER'] text: str url: Optional[str] = None phone_number: Optional[str] = None @dataclass class MessageEvent: status: str timestamp: str provider: str error_code: Optional[str] = None error_message: Optional[str] = None @dataclass class SendMessageRequest: to: List[str] template: 'TemplateRef' channel: Optional[List[str]] = None sandbox: Optional[bool] = None @dataclass class TemplateRef: id: Optional[str] = None name: Optional[str] = None parameters: Optional[Dict[str, str]] = None ``` ```go // Message represents an outbound message sent to a contact type Message struct { ID string `json:"id"` CustomerID string `json:"customer_id"` ContactID string `json:"contact_id"` Phone string `json:"phone"` PhoneInternational string `json:"phone_international"` RegionCode string `json:"region_code"` TemplateID string `json:"template_id"` TemplateName string `json:"template_name"` TemplateCategory string `json:"template_category"` Channel string `json:"channel"` MessageBody *MessageBody `json:"message_body"` Status MessageStatus `json:"status"` CreatedAt string `json:"created_at"` Price float64 `json:"price"` Events []MessageEvent `json:"events"` } // MessageStatus represents the status of a message type MessageStatus string const ( MessageStatusQueued MessageStatus = "QUEUED" MessageStatusAccepted MessageStatus = "ACCEPTED" MessageStatusSent MessageStatus = "SENT" MessageStatusDelivered MessageStatus = "DELIVERED" MessageStatusRead MessageStatus = "READ" MessageStatusFailed MessageStatus = "FAILED" ) // MessageBody represents the rendered message content type MessageBody struct { Body string `json:"body"` Header *MessageHeader `json:"header,omitempty"` Footer *MessageFooter `json:"footer,omitempty"` Buttons []MessageButton `json:"buttons,omitempty"` } // MessageHeader represents the header of a message type MessageHeader struct { Type string `json:"type"` // TEXT, IMAGE, VIDEO, DOCUMENT Content string `json:"content"` } // MessageFooter represents the footer of a message type MessageFooter struct { Content string `json:"content"` } // MessageButton represents a button in a message type MessageButton struct { Type string `json:"type"` // QUICK_REPLY, URL, PHONE_NUMBER Text string `json:"text"` URL string `json:"url,omitempty"` PhoneNumber string `json:"phone_number,omitempty"` } // MessageEvent represents a delivery event for a message type MessageEvent struct { Status string `json:"status"` Timestamp string `json:"timestamp"` Provider string `json:"provider"` ErrorCode string `json:"error_code,omitempty"` ErrorMessage string `json:"error_message,omitempty"` } // SendMessageRequest represents the request body for sending a message type SendMessageRequest struct { To []string `json:"to"` Template TemplateRef `json:"template"` Channel []string `json:"channel,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } // TemplateRef represents a template reference for sending messages type TemplateRef struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Parameters map[string]string `json:"parameters,omitempty"` } ``` --- ## Template Models ### TemplateResponse A template is a reusable message format with variables for personalization. | Field | Type | Description | |-------|------|-------------| | `id` | string (uuid) | Unique template identifier | | `name` | string | Template display name | | `category` | string | Template category: `MARKETING`, `UTILITY`, `AUTHENTICATION` | | `language` | string | Language code (e.g., `en_US`) | | `status` | string | Approval status: `APPROVED`, `PENDING`, `REJECTED` | | `channels` | string[] | Supported channels (`sms`, `whatsapp`) | | `variables` | string[] | Template variable names | | `created_at` | string (date-time) | When the template was created | | `updated_at` | string (date-time) \| null | When the template was last updated | | `is_published` | boolean | Whether the template is published and active | ### Type Definitions ```typescript export interface Template { id: string; name: string; category: TemplateCategory; language: string; status: TemplateStatus; channels: string[]; variables: string[]; created_at: string; updated_at: string | null; is_published: boolean; } export interface TemplateBody { content: string; variables?: TemplateVariable[]; } export interface TemplateHeader { type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; content: string; } export interface TemplateFooter { content: string; } export interface TemplateButton { type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER'; text: string; url?: string; phone_number?: string; } export interface TemplateVariable { name: string; type: 'text' | 'number' | 'date'; example?: string; } export interface CreateTemplateRequest { name: string; category: TemplateCategory; language: string; body: TemplateBody; header?: TemplateHeader; footer?: TemplateFooter; buttons?: TemplateButton[]; channels?: string[]; sandbox?: boolean; } export interface UpdateTemplateRequest { name?: string; category?: TemplateCategory; body?: TemplateBody; sandbox?: boolean; } export type TemplateCategory = 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'; export type TemplateStatus = 'APPROVED' | 'PENDING' | 'REJECTED'; ``` ```python from dataclasses import dataclass from typing import Optional, List, Literal @dataclass class Template: id: str name: str category: str language: str status: str channels: List[str] variables: List[str] created_at: str updated_at: Optional[str] is_published: bool @dataclass class TemplateBody: content: str variables: Optional[List['TemplateVariable']] = None @dataclass class TemplateHeader: type: Literal['TEXT', 'IMAGE', 'VIDEO', 'DOCUMENT'] content: str @dataclass class TemplateFooter: content: str @dataclass class TemplateButton: type: Literal['QUICK_REPLY', 'URL', 'PHONE_NUMBER'] text: str url: Optional[str] = None phone_number: Optional[str] = None @dataclass class TemplateVariable: name: str type: Literal['text', 'number', 'date'] example: Optional[str] = None @dataclass class CreateTemplateRequest: name: str category: str language: str body: TemplateBody header: Optional[TemplateHeader] = None footer: Optional[TemplateFooter] = None buttons: Optional[List[TemplateButton]] = None channels: Optional[List[str]] = None sandbox: Optional[bool] = None @dataclass class UpdateTemplateRequest: name: Optional[str] = None category: Optional[str] = None body: Optional[TemplateBody] = None sandbox: Optional[bool] = None ``` ```go // Template represents a reusable message format with variables for personalization type Template struct { ID string `json:"id"` Name string `json:"name"` Category TemplateCategory `json:"category"` Language string `json:"language"` Status TemplateStatus `json:"status"` Channels []string `json:"channels"` Variables []string `json:"variables"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` IsPublished bool `json:"is_published"` } // TemplateCategory represents the category of a template type TemplateCategory string const ( TemplateCategoryMarketing TemplateCategory = "MARKETING" TemplateCategoryUtility TemplateCategory = "UTILITY" TemplateCategoryAuthentication TemplateCategory = "AUTHENTICATION" ) // TemplateStatus represents the approval status of a template type TemplateStatus string const ( TemplateStatusApproved TemplateStatus = "APPROVED" TemplateStatusPending TemplateStatus = "PENDING" TemplateStatusRejected TemplateStatus = "REJECTED" ) // TemplateBody represents the body of a template type TemplateBody struct { Content string `json:"content"` Variables []TemplateVariable `json:"variables,omitempty"` } // TemplateHeader represents the header of a template type TemplateHeader struct { Type string `json:"type"` // TEXT, IMAGE, VIDEO, DOCUMENT Content string `json:"content"` } // TemplateFooter represents the footer of a template type TemplateFooter struct { Content string `json:"content"` } // TemplateButton represents a button in a template type TemplateButton struct { Type string `json:"type"` // QUICK_REPLY, URL, PHONE_NUMBER Text string `json:"text"` URL string `json:"url,omitempty"` PhoneNumber string `json:"phone_number,omitempty"` } // TemplateVariable represents a variable in a template type TemplateVariable struct { Name string `json:"name"` Type string `json:"type"` // text, number, date Example string `json:"example,omitempty"` } // CreateTemplateRequest represents the request body for creating a template type CreateTemplateRequest struct { Name string `json:"name"` Category TemplateCategory `json:"category"` Language string `json:"language"` Body TemplateBody `json:"body"` Header *TemplateHeader `json:"header,omitempty"` Footer *TemplateFooter `json:"footer,omitempty"` Buttons []TemplateButton `json:"buttons,omitempty"` Channels []string `json:"channels,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } // UpdateTemplateRequest represents the request body for updating a template type UpdateTemplateRequest struct { Name string `json:"name,omitempty"` Category TemplateCategory `json:"category,omitempty"` Body *TemplateBody `json:"body,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } ``` --- ## Profile Models ### ProfileResponse A profile (sender profile) represents an organization or sub-account for sending messages. | Field | Type | Description | |-------|------|-------------| | `id` | string (uuid) | Unique profile identifier | | `name` | string | Profile name | | `icon` | string \| null | Profile icon URL | | `description` | string \| null | Profile description | | `short_name` | string \| null | Short name/abbreviation | | `role` | string \| null | User's role: `admin`, `billing`, `developer` | | `status` | string \| null | Setup status: `incomplete`, `pending_review`, `approved`, `rejected` | | `created_at` | string (date-time) | When the profile was created | | `settings` | ProfileSettings | Profile configuration | ### Type Definitions ```typescript export interface Profile { id: string; name: string; icon: string | null; description: string | null; short_name: string | null; role: 'admin' | 'billing' | 'developer' | null; status: 'incomplete' | 'pending_review' | 'approved' | 'rejected' | null; created_at: string; settings: ProfileSettings; } export interface ProfileSettings { default_channel: string | null; webhook_url: string | null; timezone: string | null; language: string | null; } export interface CreateProfileRequest { name: string; description?: string; short_name?: string; sandbox?: boolean; } ``` ```python from dataclasses import dataclass from typing import Optional, Literal @dataclass class Profile: id: str name: str icon: Optional[str] description: Optional[str] short_name: Optional[str] role: Optional[Literal['admin', 'billing', 'developer']] status: Optional[Literal['incomplete', 'pending_review', 'approved', 'rejected']] created_at: str settings: 'ProfileSettings' @dataclass class ProfileSettings: default_channel: Optional[str] webhook_url: Optional[str] timezone: Optional[str] language: Optional[str] @dataclass class CreateProfileRequest: name: str description: Optional[str] = None short_name: Optional[str] = None sandbox: Optional[bool] = None ``` ```go // Profile represents a sender profile for sending messages type Profile struct { ID string `json:"id"` Name string `json:"name"` Icon string `json:"icon"` Description string `json:"description"` ShortName string `json:"short_name"` Role ProfileRole `json:"role"` Status ProfileStatus `json:"status"` CreatedAt string `json:"created_at"` Settings ProfileSettings `json:"settings"` } // ProfileRole represents the user's role in a profile type ProfileRole string const ( ProfileRoleAdmin ProfileRole = "admin" ProfileRoleBilling ProfileRole = "billing" ProfileRoleDeveloper ProfileRole = "developer" ) // ProfileStatus represents the setup status of a profile type ProfileStatus string const ( ProfileStatusIncomplete ProfileStatus = "incomplete" ProfileStatusPendingReview ProfileStatus = "pending_review" ProfileStatusApproved ProfileStatus = "approved" ProfileStatusRejected ProfileStatus = "rejected" ) // ProfileSettings represents the configuration for a profile type ProfileSettings struct { DefaultChannel string `json:"default_channel"` WebhookURL string `json:"webhook_url"` Timezone string `json:"timezone"` Language string `json:"language"` } // CreateProfileRequest represents the request body for creating a profile type CreateProfileRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` ShortName string `json:"short_name,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } ``` --- ## Webhook Models ### WebhookV3Response A webhook endpoint receives event notifications for message delivery and other events. | Field | Type | Description | |-------|------|-------------| | `id` | string (uuid) | Unique webhook identifier | | `display_name` | string | Webhook display name | | `endpoint_url` | string | Webhook endpoint URL | | `event_types` | string[] | Subscribed event types | | `is_active` | boolean | Whether the webhook is active | | `signing_secret` | string \| null | Secret for verifying webhook signatures | | `retry_count` | integer | Number of retry attempts (1-5, default: 3) | | `timeout_seconds` | integer | Request timeout in seconds (5-120, default: 30) | | `last_delivery_attempt_at` | string (date-time) \| null | Last delivery attempt timestamp | | `last_successful_delivery_at` | string (date-time) \| null | Last successful delivery timestamp | | `consecutive_failures` | integer | Consecutive failed delivery attempts | | `created_at` | string (date-time) | When the webhook was created | | `updated_at` | string (date-time) \| null | When the webhook was last updated | ### Type Definitions ```typescript export interface Webhook { id: string; display_name: string; endpoint_url: string; event_types: string[]; is_active: boolean; signing_secret: string | null; retry_count: number; timeout_seconds: number; last_delivery_attempt_at: string | null; last_successful_delivery_at: string | null; consecutive_failures: number; created_at: string; updated_at: string | null; } export interface WebhookEvent { id: string; webhook_id: string; event_type: string; payload: Record; status: string; response_status_code?: number; created_at: string; } export interface WebhookEventType { name: string; description: string; category: string; } export interface CreateWebhookRequest { display_name: string; endpoint_url?: string; event_types?: string[]; retry_count?: number; timeout_seconds?: number; sandbox?: boolean; } export interface UpdateWebhookRequest { display_name?: string; endpoint_url?: string; event_types?: string[]; retry_count?: number; timeout_seconds?: number; sandbox?: boolean; } ``` ```python from dataclasses import dataclass from typing import Optional, List, Dict, Any @dataclass class Webhook: id: str display_name: str endpoint_url: str event_types: List[str] is_active: bool signing_secret: Optional[str] retry_count: int timeout_seconds: int last_delivery_attempt_at: Optional[str] last_successful_delivery_at: Optional[str] consecutive_failures: int created_at: str updated_at: Optional[str] @dataclass class WebhookEvent: id: str webhook_id: str event_type: str payload: Dict[str, Any] status: str response_status_code: Optional[int] = None created_at: str @dataclass class WebhookEventType: name: str description: str category: str @dataclass class CreateWebhookRequest: display_name: str endpoint_url: Optional[str] = None event_types: Optional[List[str]] = None retry_count: Optional[int] = None timeout_seconds: Optional[int] = None sandbox: Optional[bool] = None @dataclass class UpdateWebhookRequest: display_name: Optional[str] = None endpoint_url: Optional[str] = None event_types: Optional[List[str]] = None retry_count: Optional[int] = None timeout_seconds: Optional[int] = None sandbox: Optional[bool] = None ``` ```go // Webhook represents a webhook endpoint for receiving event notifications type Webhook struct { ID string `json:"id"` DisplayName string `json:"display_name"` EndpointURL string `json:"endpoint_url"` EventTypes []string `json:"event_types"` IsActive bool `json:"is_active"` SigningSecret string `json:"signing_secret"` RetryCount int `json:"retry_count"` TimeoutSeconds int `json:"timeout_seconds"` LastDeliveryAttemptAt string `json:"last_delivery_attempt_at"` LastSuccessfulDeliveryAt string `json:"last_successful_delivery_at"` ConsecutiveFailures int `json:"consecutive_failures"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // WebhookEvent represents a single webhook delivery attempt type WebhookEvent struct { ID string `json:"id"` WebhookID string `json:"webhook_id"` EventType string `json:"event_type"` Payload map[string]interface{} `json:"payload"` Status string `json:"status"` ResponseStatusCode int `json:"response_status_code,omitempty"` CreatedAt string `json:"created_at"` } // WebhookEventType represents an available webhook event type type WebhookEventType struct { Name string `json:"name"` Description string `json:"description"` Category string `json:"category"` } // CreateWebhookRequest represents the request body for creating a webhook type CreateWebhookRequest struct { DisplayName string `json:"display_name"` EndpointURL string `json:"endpoint_url,omitempty"` EventTypes []string `json:"event_types,omitempty"` RetryCount int `json:"retry_count,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } // UpdateWebhookRequest represents the request body for updating a webhook type UpdateWebhookRequest struct { DisplayName string `json:"display_name,omitempty"` EndpointURL string `json:"endpoint_url,omitempty"` EventTypes []string `json:"event_types,omitempty"` RetryCount int `json:"retry_count,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` Sandbox bool `json:"sandbox,omitempty"` } ``` --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/error-catalog.txt TITLE: Error Catalog ================================================================================ URL: https://docs.sent.dm/llms/reference/api/error-catalog.txt Comprehensive reference of all error codes, causes, and remediation steps for the Sent API v3 # Error Catalog Comprehensive catalog of all errors you may encounter when using the Sent API v3, including their causes and step-by-step remediation steps. --- ## Authentication Errors ### AUTH_001: User is not authenticated **Error Message:** "User is not authenticated" **HTTP Status:** 401 Unauthorized **Cause:** The request is missing the required `x-api-key` header. **Remediation:** 1. Ensure you're including the `x-api-key` header in all API requests 2. Verify the header name is exactly `x-api-key` (case-sensitive) 3. Check that your API key is being loaded from environment variables correctly **Example Fix:** ```bash # ❌ Missing header curl -X GET https://api.sent.dm/v3/me # ✅ Correct curl -X GET https://api.sent.dm/v3/me \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` --- ### AUTH_002: Invalid or missing API key **Error Message:** "Invalid or missing API key" **HTTP Status:** 401 Unauthorized **Cause:** The provided API key is invalid, revoked, or the `x-api-key` header is present but its value is not recognised. **Remediation:** 1. Verify your API key is correct and complete 2. Check that you're using the correct key type (live vs test) 3. Log into your [Sent Dashboard](https://app.sent.dm) and verify the key is active 4. Generate a new API key if the current one was revoked --- ### AUTH_005: Account not yet activated **Error Message:** "Your account is not yet activated. Please wait for account activation before using the API." **HTTP Status:** 403 Forbidden **Cause:** The API key is valid and channel setup is complete, but the account is still pending final activation by Sent. **Remediation:** 1. You have completed all required setup steps — no action is needed from your side 2. Wait for the activation confirmation email from Sent 3. Contact [support@sent.dm](mailto:support@sent.dm) if activation takes longer than expected --- ### AUTH_006: KYC verification not complete **Error Message:** "Your KYC verification is not complete. Please submit your KYC documents before using the API." **HTTP Status:** 403 Forbidden **Cause:** The API key is valid but the account has not completed KYC verification. Applies to accounts in status: `SIGNED_UP`, `KYC_STARTED`, `WHITELISTED`, `ONBOARDING_STARTED`, or `KYC_RESUBMISSION_REQUESTED`. **Remediation:** 1. Log into your [Sent Dashboard](https://app.sent.dm) and complete the KYC verification flow 2. If resubmission was requested, address the flagged items and resubmit your documents 3. Contact [support@sent.dm](mailto:support@sent.dm) if you need assistance with KYC --- ### AUTH_004: Insufficient permissions **Error Message:** "Insufficient permissions for this operation" **HTTP Status:** 403 Forbidden **Cause:** Your user role doesn't have permission to perform this operation. **Remediation:** 1. Check your organization role (owner, admin, developer, billing) 2. Contact your organization owner to request additional permissions 3. Some operations require owner or admin privileges --- ### AUTH_007: Channel setup not complete **Error Message:** "Your channel setup is not complete. Please configure at least one messaging channel before using the API." **HTTP Status:** 403 Forbidden **Cause:** The API key is valid and KYC is approved, but no messaging channel (SMS or WhatsApp) has been configured. Applies to accounts in status: `KYC_COMPLETED` or `MESSAGE_COMPLIANCE_COMPLETED`. **Remediation:** 1. Log into your [Sent Dashboard](https://app.sent.dm) and complete channel setup 2. Configure at least one SMS or WhatsApp sender 3. See [Channel Setup](/start/quickstart/channel-setup) for step-by-step instructions --- ## Validation Errors ### VALIDATION_001: Request validation failed **Error Message:** "Request validation failed" **HTTP Status:** 400 Bad Request **Cause:** The request body or parameters failed validation. Check the `details` field for specific field-level errors. **Remediation:** 1. Review the `error.details` object for field-specific error messages 2. Ensure all required fields are provided 3. Verify data types match the schema (e.g., strings vs numbers) **Example:** ```json { "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "phone_number": ["Phone number is required"], "template_id": ["Invalid UUID format"] } } } ``` --- ### VALIDATION_002: Invalid phone number format **Error Message:** "Invalid phone number format" **HTTP Status:** 400 Bad Request **Cause:** The phone number is not in a valid format. **Remediation:** 1. Use E.164 format: `+1234567890` 2. Include the country code (e.g., `+1` for US) 3. Remove any non-numeric characters except the leading `+` **Valid Examples:** - `+1234567890` (US) - `+447911123456` (UK) - `+919876543210` (India) --- ### VALIDATION_003: Invalid GUID format **Error Message:** "Invalid GUID format" **HTTP Status:** 400 Bad Request **Cause:** A UUID field contains an invalid format. **Remediation:** 1. Ensure UUIDs follow the format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` 2. Verify the UUID is complete (36 characters including hyphens) 3. Check that you're not passing an empty string or null **Valid Example:** `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` --- ### VALIDATION_004: Required field is missing **Error Message:** "Required field is missing" **HTTP Status:** 400 Bad Request **Cause:** A required field is not present in the request body. **Remediation:** 1. Check the API documentation for required fields 2. Ensure the field name is spelled correctly (snake_case) 3. Verify the field is not null or undefined --- ### VALIDATION_005: Field value out of valid range **Error Message:** "Field value out of valid range" **HTTP Status:** 400 Bad Request **Cause:** A numeric field value is outside the allowed minimum/maximum range. **Remediation:** 1. Check the API documentation for valid ranges 2. For `retry_count`: must be between 1 and 5 3. For `timeout_seconds`: must be between 5 and 120 4. Ensure integer values are not negative where prohibited **Example:** ```json { "error": { "code": "VALIDATION_005", "message": "Field value out of valid range", "details": { "retry_count": ["Value must be between 1 and 5"] } } } ``` --- ### VALIDATION_006: Invalid enum value **Error Message:** "Invalid enum value" **HTTP Status:** 400 Bad Request **Cause:** A field value is not one of the allowed enum values. **Remediation:** 1. Check the API documentation for allowed values 2. Verify the value matches exactly (case-sensitive) 3. Common enums: channel (`sms`, `whatsapp`), template category (`MARKETING`, `UTILITY`, `AUTHENTICATION`) --- ### VALIDATION_007: Invalid Idempotency-Key format **Error Message:** "Invalid Idempotency-Key format" **HTTP Status:** 400 Bad Request **Cause:** The idempotency key doesn't meet the format requirements. **Remediation:** 1. Use only alphanumeric characters, hyphens, and underscores 2. Keep the key between 1-255 characters 3. Avoid special characters like spaces, `@`, `#`, etc. **Valid Examples:** - `req-abc-123` - `send_msg_001` - `webhook_retry_1` --- ## Resource Errors ### RESOURCE_001: Contact not found **Error Message:** "Contact not found" **HTTP Status:** 404 Not Found **Cause:** The specified contact ID doesn't exist or doesn't belong to your account. **Remediation:** 1. Verify the contact ID is correct 2. List all contacts to find the correct ID: `GET /v3/contacts` 3. Check that you're using the correct API key for the account that owns the contact **Debug Steps:** ```bash # List contacts to find valid IDs curl -X GET https://api.sent.dm/v3/contacts \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` --- ### RESOURCE_002: Template not found **Error Message:** "Template not found" **HTTP Status:** 404 Not Found **Cause:** The specified template ID doesn't exist. **Remediation:** 1. Verify the template ID is correct 2. List all templates: `GET /v3/templates` 3. Ensure the template hasn't been deleted --- ### RESOURCE_003: Message not found **Error Message:** "Message not found" **HTTP Status:** 404 Not Found **Cause:** The specified message ID doesn't exist. **Remediation:** 1. Verify the message ID is correct 2. Note that message IDs are only available after sending 3. Messages may be purged after retention period --- ### RESOURCE_004: Customer not found **Error Message:** "Customer not found" **HTTP Status:** 404 Not Found **Cause:** The specified customer ID doesn't exist or is not accessible. **Remediation:** 1. Verify the customer ID is correct 2. Check that your API key has access to this customer 3. Contact support if the customer should exist --- ### RESOURCE_005: Organization not found **Error Message:** "Organization not found" **HTTP Status:** 404 Not Found **Cause:** The specified organization ID doesn't exist or you don't have access. **Remediation:** 1. Verify the organization ID is correct 2. Ensure you're using an organization-level API key 3. Check that your account is a member of the organization --- ### RESOURCE_006: User not found **Error Message:** "User not found" **HTTP Status:** 404 Not Found **Cause:** The specified user ID doesn't exist in your organization. **Remediation:** 1. Verify the user ID is correct 2. List organization users to find valid IDs 3. The user may have been removed from the organization --- ### RESOURCE_007: Resource already exists **Error Message:** "Resource already exists" **HTTP Status:** 409 Conflict **Cause:** Attempting to create a resource that already exists (e.g., duplicate contact). **Remediation:** 1. Check if the resource already exists using a list or get endpoint 2. Update the existing resource instead of creating 3. Use a unique identifier for your idempotency key --- ### RESOURCE_008: Webhook not found **Error Message:** "Webhook not found" **HTTP Status:** 404 Not Found **Cause:** The specified webhook ID doesn't exist. **Remediation:** 1. Verify the webhook ID is correct 2. List all webhooks: `GET /v3/webhooks` 3. Check if the webhook was deleted --- ## Business Logic Errors ### BUSINESS_001: Cannot modify inherited contact **Error Message:** "Cannot modify inherited contact" **HTTP Status:** 422 Unprocessable Entity **Cause:** You're attempting to modify a contact that was inherited from a parent organization or shared profile. **Remediation:** 1. Create a new contact with the desired phone number 2. Contacts inherited from parent organizations are read-only 3. Use your own profile-scoped API key for contact modifications --- ### BUSINESS_002: Rate limit exceeded **Error Message:** "Rate limit exceeded" **HTTP Status:** 429 Too Many Requests **Cause:** You've exceeded the allowed number of requests per minute. **Remediation:** 1. Check the `Retry-After` header for wait time 2. Implement exponential backoff in your code 3. Consider using webhooks instead of polling 4. Contact support if you need higher limits **See:** [Rate Limits Documentation](/reference/api/rate-limits) --- ### BUSINESS_003: Insufficient account balance **Error Message:** "Insufficient account balance" **HTTP Status:** 422 Unprocessable Entity **Cause:** Your account doesn't have enough credit to complete the operation. **Remediation:** 1. Add funds to your account in the [Dashboard](https://app.sent.dm) 2. Check your current balance: `GET /v3/me` 3. Review pricing for the operation you're attempting --- ### BUSINESS_004: Contact has opted out **Error Message:** "Contact has opted out of messaging" **HTTP Status:** 422 Unprocessable Entity **Cause:** The contact has opted out of receiving messages. **Remediation:** 1. Remove the contact from your messaging lists 2. Do not attempt to message opted-out contacts 3. Respect the contact's preferences per compliance requirements --- ### BUSINESS_005: Template not approved **Error Message:** "Template not approved for sending" **HTTP Status:** 422 Unprocessable Entity **Cause:** The WhatsApp template hasn't been approved yet. **Remediation:** 1. Check template status: `GET /v3/templates/{id}` 2. Wait for WhatsApp/Meta approval (typically 24-48 hours) 3. For urgent needs, use SMS channel instead 4. Review template guidelines to ensure approval --- ### BUSINESS_006: Message cannot be modified in current state **Error Message:** "Message cannot be modified in current state" **HTTP Status:** 422 Unprocessable Entity **Cause:** The message has already been sent or is in a final state that prevents modification. **Remediation:** 1. Messages can only be modified while in `QUEUED` or `ACCEPTED` status 2. Once a message is `SENT`, `DELIVERED`, `READ`, or `FAILED`, it cannot be modified 3. Send a new message if you need to make changes --- ### BUSINESS_007: Channel not available **Error Message:** "Channel not available for this contact" **HTTP Status:** 422 Unprocessable Entity **Cause:** The requested messaging channel (SMS/WhatsApp) isn't available for this phone number. **Remediation:** 1. Check available channels for the contact: ```bash curl -X GET https://api.sent.dm/v3/contacts/{id} \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` 2. Use an available channel from `available_channels` 3. Don't specify a channel to let the API choose automatically --- ### BUSINESS_008: Operation would exceed quota **Error Message:** "Operation would exceed quota" **HTTP Status:** 422 Unprocessable Entity **Cause:** The operation would exceed your account's quota limits (messages, contacts, templates, etc.). **Remediation:** 1. Check your current usage in the [Dashboard](https://app.sent.dm) 2. Upgrade your plan to increase quotas 3. Delete unused resources to free up quota 4. Contact support for temporary quota increases --- ## Conflict Errors ### CONFLICT_001: Concurrent idempotent request **Error Message:** "Concurrent idempotent request in progress" **HTTP Status:** 409 Conflict **Cause:** Another request with the same idempotency key is currently being processed. **Remediation:** 1. Wait for the original request to complete 2. Use a unique idempotency key for each distinct operation 3. Don't reuse idempotency keys across different operations --- ## Internal Errors ### INTERNAL_001: Unexpected internal server error **Error Message:** "Unexpected internal server error" **HTTP Status:** 500 Internal Server Error **Cause:** An unexpected error occurred on the server. **Remediation:** 1. Retry the request after a short delay 2. If the error persists, contact support with: - The `request_id` from the response - Timestamp of the error - The operation you were attempting --- ### INTERNAL_002: Database operation failed **Error Message:** "Database operation failed" **HTTP Status:** 500 Internal Server Error **Cause:** An unexpected database error occurred while processing your request. **Remediation:** 1. Retry the request after a short delay 2. If the error persists, contact support with the request ID 3. This is typically a transient issue --- ### INTERNAL_003: External service error **Error Message:** "External service error (SMS/WhatsApp provider)" **HTTP Status:** 502 Bad Gateway **Cause:** The upstream messaging provider is experiencing issues. **Remediation:** 1. Wait a few minutes and retry 2. Check [API Status](https://status.sent.dm) for known issues 3. The message will be queued and retried automatically --- ### INTERNAL_004: Timeout waiting for operation **Error Message:** "Timeout waiting for operation" **HTTP Status:** 504 Gateway Timeout **Cause:** The operation timed out while waiting for an external service or internal processing. **Remediation:** 1. The operation may still be in progress - check the resource status 2. Retry the request with the same idempotency key 3. Contact support if timeouts persist --- ### INTERNAL_005: Service temporarily unavailable **Error Message:** "Service temporarily unavailable" **HTTP Status:** 503 Service Unavailable **Cause:** The API is temporarily unavailable due to maintenance or high load. **Remediation:** 1. Retry with exponential backoff 2. Check [API Status](https://status.sent.dm) 3. Wait for service restoration --- ## Troubleshooting Guide ### General Troubleshooting Steps 1. **Check the Error Code**: Use the error code to find specific guidance above 2. **Review Request ID**: Include `meta.request_id` when contacting support 3. **Verify API Version**: Ensure you're using v3 endpoints (`/v3/`) 4. **Test in Sandbox Mode**: Use `sandbox: true` to validate without side effects ### Getting Help If you can't resolve an error: 1. **Documentation**: Check this catalog and the [Error Handling](/reference/api/errors) guide 2. **Support Email**: [support@sent.dm](mailto:support@sent.dm) 3. **Include in Support Request:** - Request ID (`meta.request_id`) - Error code and message - Timestamp of occurrence - Endpoint and method - Request payload (sanitized) --- ## Error Code Quick Reference | Code | Category | HTTP Status | Quick Fix | |------|----------|-------------|-----------| | AUTH_001 | Authentication | 401 | Add `x-api-key` header | | AUTH_002 | Authentication | 401 | Verify/regenerate API key | | AUTH_004 | Authorization | 403 | Check user permissions | | AUTH_005 | Authorization | 403 | Wait for account activation | | AUTH_006 | Authorization | 403 | Complete KYC verification | | AUTH_007 | Authorization | 403 | Configure a messaging channel | | VALIDATION_001 | Validation | 400 | Check `error.details` | | VALIDATION_002 | Validation | 400 | Use E.164 phone format | | VALIDATION_003 | Validation | 400 | Check UUID format | | VALIDATION_005 | Validation | 400 | Check value is within range | | VALIDATION_006 | Validation | 400 | Check enum values | | RESOURCE_001 | Resource | 404 | Verify contact ID exists | | RESOURCE_002 | Resource | 404 | Verify template ID exists | | RESOURCE_004 | Resource | 404 | Verify customer ID exists | | RESOURCE_005 | Resource | 404 | Verify organization ID | | RESOURCE_006 | Resource | 404 | Verify user ID exists | | BUSINESS_001 | Business Logic | 422 | Create new contact instead | | BUSINESS_002 | Rate Limit | 429 | Implement backoff | | BUSINESS_003 | Billing | 422 | Add account funds | | BUSINESS_006 | Business Logic | 422 | Message already sent | | BUSINESS_008 | Business Logic | 422 | Check quota limits | | INTERNAL_001 | Server | 500 | Retry or contact support | | INTERNAL_002 | Server | 500 | Retry or contact support | | INTERNAL_004 | Server | 504 | Retry or contact support | **Need More Help?** Contact [support@sent.dm](mailto:support@sent.dm) with your request ID for personalized assistance. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/errors.txt TITLE: Error Handling ================================================================================ URL: https://docs.sent.dm/llms/reference/api/errors.txt Understanding error responses, HTTP status codes, and troubleshooting common issues in the Sent API v3 # Error Handling All errors in the Sent API v3 follow a consistent JSON envelope format with structured error codes, making it easy to programmatically handle errors and troubleshoot issues. --- ## Error Response Format All errors follow a consistent JSON envelope: ```json { "success": false, "data": null, "error": { "code": "RESOURCE_001", "message": "Contact not found", "details": null, "doc_url": "https://docs.sent.dm/errors/RESOURCE_001" }, "meta": { "request_id": "req_abc123", "timestamp": "2024-01-15T10:30:00Z", "version": "v3" } } ``` ### Error Object Fields | Field | Type | Description | |-------|------|-------------| | `code` | string | Machine-readable error code (e.g., `RESOURCE_001`) | | `message` | string | Human-readable error message | | `details` | object \| null | Additional validation error details with field-level errors | | `doc_url` | string \| null | URL to detailed documentation about this error | ### Meta Object Fields | Field | Type | Description | |-------|------|-------------| | `request_id` | string | Unique request identifier for support and debugging | | `timestamp` | string | ISO 8601 timestamp of the error | | `version` | string | API version (always `v3`) | --- ## HTTP Status Codes | Status | Description | Common Causes | |--------|-------------|---------------| | `200 OK` | Request successful | - | | `201 Created` | Resource created successfully | - | | `204 No Content` | Request successful, no response body | DELETE operations | | `400 Bad Request` | Invalid request format or parameters | Missing required fields, invalid JSON | | `401 Unauthorized` | Authentication failed | Missing or invalid API key | | `403 Forbidden` | Permission denied | Insufficient role permissions | | `404 Not Found` | Resource not found | Invalid resource ID | | `409 Conflict` | Resource conflict | Duplicate entry, concurrent modification | | `422 Unprocessable Entity` | Validation failed | Invalid field values, business rule violations | | `429 Too Many Requests` | Rate limit exceeded | Too many requests in time window | | `500 Internal Server Error` | Unexpected server error | Server-side issue | | `502 Bad Gateway` | Upstream service error | Provider service unavailable | | `503 Service Unavailable` | Service temporarily unavailable | Maintenance or overload | --- ## Error Code Reference ### Authentication Errors (AUTH_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `AUTH_001` | 401 | User is not authenticated | Include the `x-api-key` header with a valid API key | | `AUTH_002` | 401 | Invalid or expired API key | Check your API key and generate a new one if needed | | `AUTH_004` | 403 | Insufficient permissions for this operation | Verify your user role has permission for this action | ### Validation Errors (VALIDATION_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `VALIDATION_001` | 400 | Request validation failed | Check the `details` field for specific field errors | | `VALIDATION_002` | 400 | Invalid phone number format | Ensure phone number is in valid E.164 or international format | | `VALIDATION_003` | 400 | Invalid GUID format | Ensure UUIDs are properly formatted (e.g., `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) | | `VALIDATION_004` | 400 | Required field is missing | Check the request body includes all required fields | | `VALIDATION_005` | 400 | Field value out of valid range | Ensure numeric values are within acceptable ranges | | `VALIDATION_006` | 400 | Invalid enum value | Use one of the allowed enum values | | `VALIDATION_007` | 400 | Invalid Idempotency-Key format | Key must be 1-255 alphanumeric characters, hyphens, or underscores | **Example Validation Error with Details:** ```json { "success": false, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "phone_number": ["Phone number is required"], "template_id": ["Invalid UUID format"] }, "doc_url": "https://docs.sent.dm/errors/VALIDATION_001" }, "meta": { ... } } ``` ### Resource Errors (RESOURCE_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `RESOURCE_001` | 404 | Contact not found | Verify the contact ID exists and belongs to your account | | `RESOURCE_002` | 404 | Template not found | Verify the template ID exists | | `RESOURCE_003` | 404 | Message not found | Verify the message ID exists | | `RESOURCE_004` | 404 | Customer not found | Contact support if this error occurs | | `RESOURCE_005` | 404 | Organization not found | Verify your account is properly configured | | `RESOURCE_006` | 404 | User not found | Verify the user ID exists | | `RESOURCE_007` | 409 | Resource already exists | Use a different unique value or update the existing resource | | `RESOURCE_008` | 404 | Webhook not found | Verify the webhook ID exists | ### Business Logic Errors (BUSINESS_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `BUSINESS_001` | 422 | Cannot modify inherited contact | This contact belongs to another customer | | `BUSINESS_002` | 429 | Rate limit exceeded | Reduce request frequency or contact support to increase limits | | `BUSINESS_003` | 422 | Insufficient account balance | Add funds to your account | | `BUSINESS_004` | 422 | Contact has opted out of messaging | Remove this contact from your messaging list | | `BUSINESS_005` | 422 | Template not approved for sending | Wait for template approval or use an approved template | | `BUSINESS_006` | 422 | Message cannot be modified in current state | Messages can only be modified in certain states | | `BUSINESS_007` | 422 | Channel not available for this contact | This phone number doesn't support the requested channel | | `BUSINESS_008` | 422 | Operation would exceed quota | Contact support to increase your quota | ### Conflict Errors (CONFLICT_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `CONFLICT_001` | 409 | Concurrent idempotent request in progress | Wait for the original request to complete before retrying | ### Service Errors (SERVICE_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `SERVICE_001` | 503 | Cache service temporarily unavailable | Retry the request after a short delay | ### Internal Errors (INTERNAL_*) | Code | HTTP Status | Message | Resolution | |------|-------------|---------|------------| | `INTERNAL_001` | 500 | Unexpected internal server error | Contact support with the request ID | | `INTERNAL_002` | 500 | Database operation failed | Retry the request; contact support if persistent | | `INTERNAL_003` | 502 | External service error (SMS/WhatsApp provider) | Provider service issue; retry after delay | | `INTERNAL_004` | 504 | Timeout waiting for operation | Retry the request with exponential backoff | | `INTERNAL_005` | 503 | Service temporarily unavailable | Service maintenance or overload; retry after delay | --- ## Common Error Scenarios ### Authentication Issues **Missing API Key** ```bash curl -X GET https://api.sent.dm/v3/me # Response: 401 AUTH_001 ``` **Solution:** Include the `x-api-key` header: ```bash curl -X GET https://api.sent.dm/v3/me \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` ### Validation Issues **Invalid Phone Number** ```json { "success": false, "error": { "code": "VALIDATION_002", "message": "Invalid phone number format", "details": { "phone_number": ["Phone number must be in E.164 format (e.g., +1234567890)"] }, "doc_url": "https://docs.sent.dm/errors/VALIDATION_002" } } ``` **Solution:** Use E.164 format: ```json { "phone_number": "+1234567890" } ``` ### Resource Not Found **Contact Not Found** ```json { "success": false, "error": { "code": "RESOURCE_001", "message": "Contact not found", "doc_url": "https://docs.sent.dm/errors/RESOURCE_001" } } ``` **Solution:** Verify the contact ID exists: ```bash curl -X GET https://api.sent.dm/v3/contacts \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ``` ### Rate Limiting **Too Many Requests** ```json { "success": false, "error": { "code": "BUSINESS_002", "message": "Rate limit exceeded", "doc_url": "https://docs.sent.dm/errors/BUSINESS_002" } } ``` **Response Headers:** ```http Retry-After: 60 X-RateLimit-Limit: 200 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1705312800 ``` **Solution:** Implement exponential backoff and respect the `Retry-After` header. --- ## Best Practices for Error Handling ### Always Check the Success Flag ```typescript const response = await fetch('/v3/messages', { ... }); const data: ApiResponse = await response.json(); if (!data.success) { // Handle error console.error(`Error ${data.error?.code}: ${data.error?.message}`); return; } // Process successful response console.log(data.data); ``` ```python import requests response = requests.post('/v3/messages', ...) data = response.json() if not data['success']: # Handle error print(f"Error {data['error']['code']}: {data['error']['message']}") return # Process successful response print(data['data']) ``` ```go resp, err := http.Post("/v3/messages", "application/json", body) if err != nil { log.Fatal(err) } defer resp.Body.Close() var data ApiResponse[Message] if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { log.Fatal(err) } if !data.Success { // Handle error log.Printf("Error %s: %s\n", data.Error.Code, data.Error.Message) return } // Process successful response log.Println(data.Data) ``` ### Handle Specific Error Codes ```typescript switch (data.error?.code) { case 'AUTH_001': case 'AUTH_002': // Redirect to login or refresh API key break; case 'RESOURCE_001': // Prompt user to create the resource break; case 'BUSINESS_002': // Implement retry with backoff const retryAfter = response.headers.get('Retry-After'); await sleep(parseInt(retryAfter || '60') * 1000); break; default: // Log unexpected error console.error('Unexpected error:', data.error); } ``` ```python error_code = data['error']['code'] if error_code in ('AUTH_001', 'AUTH_002'): # Redirect to login or refresh API key pass elif error_code == 'RESOURCE_001': # Prompt user to create the resource pass elif error_code == 'BUSINESS_002': # Implement retry with backoff retry_after = int(response.headers.get('Retry-After', 60)) time.sleep(retry_after) else: # Log unexpected error print('Unexpected error:', data['error']) ``` ```go switch data.Error.Code { case "AUTH_001", "AUTH_002": // Redirect to login or refresh API key case "RESOURCE_001": // Prompt user to create the resource case "BUSINESS_002": // Implement retry with backoff retryAfter := resp.Header.Get("Retry-After") seconds, _ := strconv.Atoi(retryAfter) if seconds == 0 { seconds = 60 } time.Sleep(time.Duration(seconds) * time.Second) default: // Log unexpected error log.Printf("Unexpected error: %+v\n", data.Error) } ``` ### Use Request IDs for Support When contacting support about an error, always include the request ID from the meta object: ```typescript console.error(`Request ID: ${data.meta.request_id}`); // Provide this to support@sent.dm when reporting issues ``` ```python print(f"Request ID: {data['meta']['request_id']}") # Provide this to support@sent.dm when reporting issues ``` ```go log.Printf("Request ID: %s\n", data.Meta.RequestID) // Provide this to support@sent.dm when reporting issues ``` ### Implement Idempotency for Retries ```typescript const idempotencyKey = `send-msg-${messageId}`; const response = await fetch('/v3/messages', { method: 'POST', headers: { 'x-api-key': apiKey, 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); ``` ```python idempotency_key = f"send-msg-{message_id}" response = requests.post( '/v3/messages', headers={ 'x-api-key': api_key, 'Idempotency-Key': idempotency_key, 'Content-Type': 'application/json' }, json=payload ) ``` ```go idempotencyKey := fmt.Sprintf("send-msg-%s", messageID) req, err := http.NewRequest("POST", "/v3/messages", body) if err != nil { log.Fatal(err) } req.Header.Set("x-api-key", apiKey) req.Header.Set("Idempotency-Key", idempotencyKey) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer resp.Body.Close() ``` ### Validate Inputs Client-Side Prevent validation errors by validating inputs before sending: ```typescript // Validate phone number format function isValidE164(phone: string): boolean { return /^\+[1-9]\d{1,14}$/.test(phone); } if (!isValidE164(phoneNumber)) { throw new Error('Phone number must be in E.164 format'); } ``` ```python import re # Validate phone number format def is_valid_e164(phone: str) -> bool: return bool(re.match(r'^\+[1-9]\d{1,14}$', phone)) if not is_valid_e164(phone_number): raise ValueError('Phone number must be in E.164 format') ``` ```go import "regexp" // Validate phone number format func isValidE164(phone string) bool { matched, _ := regexp.MatchString(`^\+[1-9]\d{1,14}$`, phone) return matched } if !isValidE164(phoneNumber) { log.Fatal("Phone number must be in E.164 format") } ``` --- ## Testing Error Scenarios Use `sandbox` to test error handling without side effects: ```bash curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "sandbox": true, "phone_number": "invalid", "template_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }' ``` This will return validation errors without actually attempting to send a message. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/idempotency.txt TITLE: Idempotency ================================================================================ URL: https://docs.sent.dm/llms/reference/api/idempotency.txt Ensure safe retries and prevent duplicate operations with idempotency keys in the Sent API v3 # Idempotency The Sent API v3 supports idempotency for safely retrying requests without accidentally performing the same operation twice. This is essential for preventing duplicate charges, messages, or resource creation when network errors or timeouts occur. --- ## What is Idempotency? An operation is **idempotent** if performing it multiple times has the same effect as performing it once. With idempotency keys, the API guarantees **at-most-once execution** for mutation operations. ### Common Use Cases - **Network timeouts**: Retry a request when the connection drops - **5xx errors**: Retry after server errors without side effects - **User retries**: Prevent duplicate form submissions - **Webhook processing**: Handle duplicate webhook deliveries safely --- ## How It Works 1. Generate a unique key for each distinct operation 2. Include it in the `Idempotency-Key` header 3. The API caches the response for 24 hours 4. Duplicate requests with the same key return the cached response ```http POST /v3/messages Idempotency-Key: msg_send_abc123 Content-Type: application/json { "phone_number": "+1234567890", "template_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "variables": { "customer_name": "John" } } ``` --- ## Supported Endpoints Idempotency is supported for all mutation endpoints: | Endpoint | Method | |----------|--------| | `/v3/messages` | POST | | `/v3/contacts` | POST, PATCH | | `/v3/contacts/{id}` | PATCH, DELETE | | `/v3/templates` | POST | | `/v3/templates/{id}` | PUT, DELETE | | `/v3/profiles` | POST | | `/v3/profiles/{id}` | PATCH, DELETE | | `/v3/profiles/{id}/complete` | POST | | `/v3/brands` | POST | | `/v3/brands/{id}` | PUT, DELETE | | `/v3/brands/{id}/campaigns` | POST | | `/v3/brands/{id}/campaigns/{id}` | PUT, DELETE | | `/v3/webhooks` | POST | | `/v3/webhooks/{id}` | PUT, DELETE, PATCH | | `/v3/webhooks/{id}/rotate-secret` | POST | | `/v3/users` | POST | | `/v3/users/{id}` | PATCH, DELETE | --- ## Idempotency Key Format ### Requirements - **Length**: 1-255 characters - **Characters**: Alphanumeric, hyphens (`-`), and underscores (`_`) - **Pattern**: `^[a-zA-Z0-9_-]+$` - **Scope**: Per customer account - **Expiration**: 24 hours ### Valid Examples ``` req-abc123 send_msg_001 webhook-retry-1 invoice-payment-2024-001 create-contact-john-doe ``` ### Invalid Examples ``` req abc 123 # Contains spaces create@contact # Contains special character @ send.msg.001 # Contains periods ``` --- ## Idempotency Key Best Practices ### 1. Generate Unique Keys Include information that makes the key unique to the operation: ```typescript // Good: Includes user action + resource + timestamp const key = `payment-${userId}-${invoiceId}-${Date.now()}`; // Good: Includes resource type and client-generated ID const key = `contact-create-${clientRequestId}`; // Bad: Static key (would block legitimate retries) const key = 'create-message'; // Bad: Random without context (hard to debug) const key = crypto.randomUUID(); ``` ```python import time import uuid # Good: Includes user action + resource + timestamp key = f"payment-{user_id}-{invoice_id}-{int(time.time() * 1000)}" # Good: Includes resource type and client-generated ID key = f"contact-create-{client_request_id}" # Bad: Static key (would block legitimate retries) key = "create-message" # Bad: Random without context (hard to debug) key = str(uuid.uuid4()) ``` ```go package main import ( "fmt" "time" "github.com/google/uuid" ) // Good: Includes user action + resource + timestamp key := fmt.Sprintf("payment-%s-%s-%d", userID, invoiceID, time.Now().UnixMilli()) // Good: Includes resource type and client-generated ID key := fmt.Sprintf("contact-create-%s", clientRequestID) // Bad: Static key (would block legitimate retries) key := "create-message" // Bad: Random without context (hard to debug) key := uuid.New().String() ``` ### 2. Use Consistent Keys for Retries Keep the same key when retrying the same operation: ```typescript async function sendMessageWithRetry( payload: MessagePayload, maxRetries = 3 ): Promise { // Generate key once for the operation const idempotencyKey = `msg-${payload.contactId}-${Date.now()}`; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch('/v3/messages', { method: 'POST', headers: { 'x-api-key': API_KEY, 'Idempotency-Key': idempotencyKey, // Same key on retry 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (response.ok) { return await response.json(); } // Don't retry on 4xx errors (client errors) if (response.status >= 400 && response.status < 500) { throw new Error(`Client error: ${response.status}`); } // Retry on 5xx or network errors if (attempt < maxRetries) { await sleep(Math.pow(2, attempt) * 1000); } } catch (error) { if (attempt === maxRetries) throw error; } } throw new Error('Max retries exceeded'); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python import time import requests def send_message_with_retry(payload: dict, max_retries: int = 3) -> dict: """Send message with idempotency and retry logic.""" # Generate key once for the operation idempotency_key = f"msg-{payload['contact_id']}-{int(time.time() * 1000)}" for attempt in range(1, max_retries + 1): try: response = requests.post( 'https://api.sent.dm/v3/messages', headers={ 'x-api-key': API_KEY, 'Idempotency-Key': idempotency_key, # Same key on retry 'Content-Type': 'application/json' }, json=payload ) if response.ok: return response.json() # Don't retry on 4xx errors (client errors) if 400 <= response.status_code < 500: raise Exception(f"Client error: {response.status_code}") # Retry on 5xx or network errors if attempt < max_retries: time.sleep(2 ** attempt) except Exception as e: if attempt == max_retries: raise e raise Exception('Max retries exceeded') ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "time" ) func sendMessageWithRetry(payload map[string]interface{}, maxRetries int) (map[string]interface{}, error) { // Generate key once for the operation contactID := payload["contact_id"].(string) idempotencyKey := fmt.Sprintf("msg-%s-%d", contactID, time.Now().UnixMilli()) for attempt := 1; attempt <= maxRetries; attempt++ { body, _ := json.Marshal(payload) req, _ := http.NewRequest( "POST", "https://api.sent.dm/v3/messages", bytes.NewBuffer(body), ) req.Header.Set("x-api-key", API_KEY) req.Header.Set("Idempotency-Key", idempotencyKey) // Same key on retry req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { if attempt == maxRetries { return nil, err } time.Sleep(time.Duration(1<= 200 && resp.StatusCode < 300 { var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) return result, nil } // Don't retry on 4xx errors (client errors) if resp.StatusCode >= 400 && resp.StatusCode < 500 { return nil, fmt.Errorf("client error: %d", resp.StatusCode) } // Retry on 5xx or network errors if attempt < maxRetries { time.Sleep(time.Duration(1< { try { return await apiRequest(url, payload, key); } catch (error: any) { if (error.code === 'CONFLICT_001') { // Wait for the original request to complete await sleep(500); return await apiRequest(url, payload, key); } throw error; } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python import time def send_with_idempotency(url: str, payload: dict, key: str) -> dict: """Send request with handling for concurrent idempotent requests.""" try: return api_request(url, payload, key) except Exception as e: error_code = getattr(e, 'code', None) if error_code == 'CONFLICT_001': # Wait for the original request to complete time.sleep(0.5) return api_request(url, payload, key) raise ``` ```go package main import ( "fmt" "time" ) func sendWithIdempotency(url string, payload map[string]interface{}, key string) (map[string]interface{}, error) { result, err := apiRequest(url, payload, key) if err != nil { if err.Error() == "CONFLICT_001" { // Wait for the original request to complete time.Sleep(500 * time.Millisecond) return apiRequest(url, payload, key) } return nil, err } return result, nil } ``` --- ## Response Headers ### Idempotent-Replayed When a cached response is returned, the `Idempotent-Replayed: true` header is included: ```http HTTP/1.1 201 Created Idempotent-Replayed: true X-Original-Request-Id: req_original_abc123 X-Request-Id: req_replay_def456 Content-Type: application/json { "success": true, "data": { ... }, "error": null, "meta": { ... } } ``` ### Header Reference | Header | Description | |--------|-------------| | `Idempotent-Replayed` | `true` if this is a cached response | | `X-Original-Request-Id` | Request ID of the original request | | `X-Request-Id` | Request ID of the current request | --- ## Code Examples Production-ready implementations with automatic key generation and replay detection. ```typescript class IdempotentClient { constructor(apiKey: string, baseUrl = 'https://api.sent.dm') { this.apiKey = apiKey; this.baseUrl = baseUrl; } async request( method: string, endpoint: string, payload?: object, options: { idempotencyKey?: string } = {} ) { const headers: Record = { 'x-api-key': this.apiKey, 'Content-Type': 'application/json' }; // Add idempotency key if provided if (options.idempotencyKey) { headers['Idempotency-Key'] = options.idempotencyKey; } const response = await fetch(`${this.baseUrl}${endpoint}`, { method, headers, body: payload ? JSON.stringify(payload) : undefined }); const data = await response.json(); // Check if this was a replay if (response.headers.get('Idempotent-Replayed')) { console.log('Idempotent replay detected'); console.log('Original request ID:', response.headers.get('X-Original-Request-Id')); } if (!data.success) { throw new Error(`${data.error.code}: ${data.error.message}`); } return data.data; } // Send message with automatic idempotency key async sendMessage( phoneNumber: string, templateId: string, variables?: Record ) { const key = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; return this.request('POST', '/v3/messages', { phone_number: phoneNumber, template_id: templateId, variables }, { idempotencyKey: key }); } } // Usage const client = new IdempotentClient(process.env.SENT_API_KEY!); // Send with automatic key generation await client.sendMessage('+1234567890', 'template-456', { name: 'John' }); // Or provide your own key for specific operations await client.request('POST', '/v3/contacts', { phone_number: '+1234567890' }, { idempotencyKey: 'import-user-12345' }); ``` ```python import time import random import requests from typing import Optional, Dict, Any class IdempotentClient: def __init__(self, api_key: str, base_url: str = 'https://api.sent.dm'): self.api_key = api_key self.base_url = base_url def _generate_key(self, prefix: str) -> str: """Generate a unique idempotency key.""" timestamp = int(time.time() * 1000) random_str = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=8)) return f"{prefix}-{timestamp}-{random_str}" def request( self, method: str, endpoint: str, payload: Optional[Dict] = None, idempotency_key: Optional[str] = None ) -> Dict[str, Any]: headers = { 'x-api-key': self.api_key, 'Content-Type': 'application/json' } if idempotency_key: headers['Idempotency-Key'] = idempotency_key response = requests.request( method, f'{self.base_url}{endpoint}', headers=headers, json=payload ) # Check for replay if response.headers.get('Idempotent-Replayed'): print('Idempotent replay detected') print(f'Original request ID: {response.headers.get("X-Original-Request-Id")}') data = response.json() if not data.get('success'): raise Exception(f"{data['error']['code']}: {data['error']['message']}") return data['data'] def send_message( self, phone_number: str, template_id: str, variables: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Send a message with automatic idempotency key.""" key = self._generate_key(f"msg-{phone_number.replace('+', '')}") return self.request('POST', '/v3/messages', { 'phone_number': phone_number, 'template_id': template_id, 'variables': variables or {} }, idempotency_key=key) def create_contact(self, phone_number: str, key_prefix: str = None) -> Dict[str, Any]: """Create a contact with idempotency key.""" key = key_prefix or self._generate_key('contact') return self.request('POST', '/v3/contacts', { 'phone_number': phone_number }, idempotency_key=key) # Usage client = IdempotentClient(api_key='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') # Send message with automatic key message = client.send_message( '+1234567890', 'template-id', {'customer_name': 'John'} ) # Create contact with custom key prefix contact = client.create_contact('+1234567890', key_prefix='import-csv-row-42') ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" "time" "math/rand" ) type IdempotentClient struct { APIKey string BaseURL string } func NewIdempotentClient(apiKey string) *IdempotentClient { return &IdempotentClient{ APIKey: apiKey, BaseURL: "https://api.sent.dm", } } func (c *IdempotentClient) generateKey(prefix string) string { timestamp := time.Now().UnixMilli() randomStr := fmt.Sprintf("%08d", rand.Intn(100000000)) return fmt.Sprintf("%s-%d-%s", prefix, timestamp, randomStr) } func (c *IdempotentClient) Request( method string, endpoint string, payload interface{}, idempotencyKey string, ) (map[string]interface{}, error) { var body []byte if payload != nil { body, _ = json.Marshal(payload) } req, err := http.NewRequest( method, c.BaseURL+endpoint, bytes.NewBuffer(body), ) if err != nil { return nil, err } req.Header.Set("x-api-key", c.APIKey) req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // Check for replay if resp.Header.Get("Idempotent-Replayed") == "true" { fmt.Println("Idempotent replay detected") fmt.Printf("Original request ID: %s\n", resp.Header.Get("X-Original-Request-Id")) } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } if success, ok := result["success"].(bool); !ok || !success { errorData := result["error"].(map[string]interface{}) return nil, fmt.Errorf("%s: %s", errorData["code"], errorData["message"]) } return result["data"].(map[string]interface{}), nil } func (c *IdempotentClient) SendMessage( phoneNumber string, templateID string, variables map[string]string, ) (map[string]interface{}, error) { key := c.generateKey(fmt.Sprintf("msg-%s", phoneNumber)) payload := map[string]interface{}{ "phone_number": phoneNumber, "template_id": templateID, "variables": variables, } return c.Request("POST", "/v3/messages", payload, key) } // Usage func main() { client := NewIdempotentClient(os.Getenv("SENT_API_KEY")) // Send with automatic key message, err := client.SendMessage( "+1234567890", "template-id", map[string]string{"customer_name": "John"}, ) if err != nil { panic(err) } fmt.Printf("Message sent: %v\n", message["id"]) // Or provide custom key contact, err := client.Request( "POST", "/v3/contacts", map[string]string{"phone_number": "+1234567890"}, "import-user-12345", ) if err != nil { panic(err) } fmt.Printf("Contact created: %v\n", contact["id"]) } ``` --- ## Idempotency vs Sandbox Mode Both features help with safe API usage, but serve different purposes: | Feature | Purpose | Side Effects | Response | |---------|---------|--------------|----------| | **Sandbox Mode** | Validate requests | None (validation only) | Fake/sample data | | **Idempotency** | Prevent duplicates | Only on first request | Real/cached data | ### Using Together You can use both features together: ```typescript // Sandbox mode without idempotency - for initial validation await client.request('POST', '/v3/messages', { sandbox: true, phone_number: '+1234567890', template_id: 'template-id' }); // Production request with idempotency - safe to retry await client.request('POST', '/v3/messages', { phone_number: '+1234567890', template_id: 'template-id' }, { idempotencyKey: 'msg-123' }); ``` ```python # Sandbox mode without idempotency - for initial validation client.request('POST', '/v3/messages', { 'sandbox': True, 'phone_number': '+1234567890', 'template_id': 'template-id' }) # Production request with idempotency - safe to retry client.request( 'POST', '/v3/messages', { 'phone_number': '+1234567890', 'template_id': 'template-id' }, idempotency_key='msg-123' ) ``` ```go // Sandbox mode without idempotency - for initial validation client.Request("POST", "/v3/messages", map[string]interface{}{ "sandbox": true, "phone_number": "+1234567890", "template_id": "template-id", }, "") // Production request with idempotency - safe to retry client.Request("POST", "/v3/messages", map[string]interface{}{ "phone_number": "+1234567890", "template_id": "template-id", }, "msg-123") ``` --- ## Troubleshooting ### Key Already Used for Different Request If you reuse a key for a different request payload, the API returns the cached response from the original request: ```typescript // First request - creates contact A await client.request('POST', '/v3/contacts', { phone_number: '+1111111111' }, { idempotencyKey: 'create-contact' }); // Second request - different payload, same key // Returns cached response for contact A, not new contact! await client.request('POST', '/v3/contacts', { phone_number: '+2222222222' // Different number! }, { idempotencyKey: 'create-contact' }); // Same key! ``` ```python # First request - creates contact A client.request( 'POST', '/v3/contacts', {'phone_number': '+1111111111'}, idempotency_key='create-contact' ) # Second request - different payload, same key # Returns cached response for contact A, not new contact! client.request( 'POST', '/v3/contacts', {'phone_number': '+2222222222'}, # Different number! idempotency_key='create-contact' # Same key! ) ``` ```go // First request - creates contact A client.Request("POST", "/v3/contacts", map[string]interface{}{ "phone_number": "+1111111111", }, "create-contact") // Second request - different payload, same key // Returns cached response for contact A, not new contact! client.Request("POST", "/v3/contacts", map[string]interface{}{ "phone_number": "+2222222222", // Different number! }, "create-contact") // Same key! ``` **Solution**: Always use unique keys for distinct operations. ### Key Expired After 24 hours, idempotency keys expire. A retry with an expired key will execute as a new request. **Solution**: Handle retries within 24 hours or implement application-level deduplication. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api.txt TITLE: Sent v3 API ================================================================================ URL: https://docs.sent.dm/llms/reference/api.txt Complete v3 API documentation for Sent's intelligent multi-channel messaging platform # Sent v3 API **Build intelligent multi-channel messaging into your applications** with the Sent v3 API – designed for developers who need reliable, scalable communication solutions. ## Quick Start Get up and running with the Sent API in just a few minutes. This section covers the essentials to send your first message through our intelligent multi-channel platform. **🚀 New to Sent?** Follow our comprehensive [Quickstart Guide](/start/quickstart) for a complete walkthrough with examples, best practices, and integration patterns. ### Sent API Base URL ``` https://api.sent.dm ``` ### Authentication The Sent API uses header-based authentication with a single required credential: ```http x-api-key: YOUR_API_KEY ``` **Required Headers:** - `x-api-key`: Your secret API key for authentication 👉 **Need API credentials?** Visit your [Sent Dashboard](https://app.sent.dm/dashboard/api-keys) to generate your API keys. **Security Notes:** - API keys are sensitive - never expose them in client-side code - Use environment variables to store credentials securely - Rotate keys regularly for enhanced security 📚 **Full Guide:** See our complete [Authentication Documentation](/reference/api/authentication) for security best practices, key management, and troubleshooting. --- ## Authentication & Security --- ## API Reference Deep-dive into schemas, error handling, and advanced integration patterns. --- ## Postman Collection Import our complete API collection and start testing immediately with pre-configured requests and environments: --- ## Support & Resources Need help? We've got you covered with comprehensive support resources and community channels. ### Documentation - **[Quickstart Guide](/start/quickstart)** - Complete onboarding walkthrough - **[Support Center](/start/support)** - FAQs, troubleshooting, and community resources - **[API Status](https://status.sent.dm)** - Real-time service status and incident reports ### Get Help - **Email Support**: [support@sent.dm](mailto:support@sent.dm) ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint.txt TITLE: Get message activities ================================================================================ URL: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint.txt # GET /v3/messages/{id}/activities Get message activities Retrieves the activity log for a specific message. Activities track the message lifecycle including acceptance, processing, sending, delivery, and any errors. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageActivitiesEndpoint` **Tags:** Messages ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Message ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Message activities retrieved successfully #### application/json ```typescript { success?: boolean, data?: { message_id?: string, activities?: Array<{ status?: string, description?: string, timestamp?: string, price?: string, active_contact_price?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "activities": [ { "status": "DELIVERED", "description": "Message delivered to recipient", "timestamp": "2026-04-08T15:29:52.0471063+00:00", "price": "0.0450", "active_contact_price": "0.0050" }, { "status": "SENT", "description": "Message sent via SMS", "timestamp": "2026-04-08T15:24:52.0474256+00:00", "price": "0.0450", "active_contact_price": "0.0050" }, { "status": "PROCESSED", "description": "Message processed and queued for sending", "timestamp": "2026-04-08T15:23:52.0474273+00:00", "price": "0.0450", "active_contact_price": "0.0050" }, { "status": "QUEUED", "description": "Message accepted and queued for processing", "timestamp": "2026-04-08T15:22:52.0474281+00:00", "price": null, "active_contact_price": null } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.04749+00:00", "version": "v3" } } ``` ### 400 Invalid message ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Message not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_003", "message": "Message not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0475039+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving message activities.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0475051+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint.txt TITLE: Get message status ================================================================================ URL: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint.txt # GET /v3/messages/{id} Get message status Retrieves the current status and details of a message by ID. Includes delivery status, timestamps, and error information if applicable. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3MessagesGetMessageEndpoint` **Tags:** Messages ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Message ID | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Message retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, customer_id?: string, contact_id?: string, phone?: string, phone_international?: string, region_code?: string, template_id?: string, template_name?: string, template_category?: string, channel?: string, message_body?: { header?: string, content?: string, footer?: string, buttons?: Array<{ type?: string, value?: string }> }, status?: string, created_at?: string, price?: number, active_contact_price?: number, events?: Array<{ status?: string, timestamp?: string, description?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "customer_id": "550e8400-e29b-41d4-a716-446655440000", "contact_id": "550e8400-e29b-41d4-a716-446655440002", "phone": "+14155551234", "phone_international": "+1 415-555-1234", "region_code": "US", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "Welcome Message", "template_category": "UTILITY", "channel": "sms", "message_body": { "header": null, "content": "Welcome to our service, John! We're excited to have you.", "footer": null, "buttons": null }, "status": "DELIVERED", "created_at": "2026-04-08T13:54:52.0582733+00:00", "price": 0.0055, "active_contact_price": 0.015, "events": [ { "status": "QUEUED", "timestamp": "2026-04-08T13:54:52.1007154+00:00", "description": "Message queued for sending" }, { "status": "SENT", "timestamp": "2026-04-08T13:54:57.101135+00:00", "description": "Message sent via SMS" }, { "status": "DELIVERED", "timestamp": "2026-04-08T13:55:02.1011577+00:00", "description": "Message delivered to recipient" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.10133+00:00", "version": "v3" } } ``` ### 400 Invalid message ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid message ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1013486+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Message not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_003", "message": "Message not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1013512+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving the message.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1013533+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint.txt TITLE: Send a message ================================================================================ URL: https://docs.sent.dm/llms/reference/api/messages/SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint.txt # POST /v3/messages Send a message Sends a message to one or more recipients using a template. Supports multi-channel broadcast — when multiple channels are specified (e.g. ["sms", "whatsapp"]), a separate message is created for each (recipient, channel) pair. Returns immediately with per-recipient message IDs for async tracking via webhooks or the GET /messages/{id} endpoint. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3MessagesSendMessageV3Endpoint` **Tags:** Messages ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 202 Message accepted for processing #### application/json ```typescript { success?: boolean, data?: { status?: string, template_id?: string, template_name?: string, recipients?: Array<{ message_id?: string, to?: string, channel?: string, body?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "status": "QUEUED", "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "template_name": "order_confirmation", "recipients": [ { "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "sms", "body": "Hi John Doe, your order #12345 has been confirmed." }, { "message_id": "8ba7b831-9dad-11d1-80b4-00c04fd430c8", "to": "+14155551234", "channel": "whatsapp", "body": "Hi John Doe, your order #12345 has been confirmed." }, { "message_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8", "to": "+14155555678", "channel": "sms", "body": "Hi John Doe, your order #12345 has been confirmed." }, { "message_id": "9ba7b841-9dad-11d1-80b4-00c04fd430c8", "to": "+14155555678", "channel": "whatsapp", "body": "Hi John Doe, your order #12345 has been confirmed." } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.132263+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_004", "message": "Request validation failed", "details": { "to": [ "'to' must contain at least one recipient" ], "template": [ "'template' is required" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.132297+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 402 Insufficient balance #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "BUSINESS_003", "message": "Insufficient balance to send message.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.132303+00:00", "version": "v3" } } ``` ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1323005+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to queue message for processing.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.1323122+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/number-lookup/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint.txt TITLE: Get phone number details ================================================================================ URL: https://docs.sent.dm/llms/reference/api/number-lookup/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint.txt # GET /v3/numbers/lookup/{phoneNumber} Get phone number details Retrieves detailed information about a phone number including carrier, line type, porting status, and VoIP detection. Uses the customer's messaging provider for rich data, with fallback to the internal index. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint` **Tags:** Numbers ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `phoneNumber` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Phone number details returned successfully #### application/json ```typescript { success?: boolean, data?: { phone_number?: string, is_valid?: boolean, carrier_name?: string, line_type?: string, country_code?: string, mobile_country_code?: string, mobile_network_code?: string, is_ported?: boolean, is_voip?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "phone_number": "+12025551234", "is_valid": true, "carrier_name": "T-Mobile", "line_type": "mobile", "country_code": "US", "mobile_country_code": "310", "mobile_network_code": "260", "is_ported": false, "is_voip": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380147+00:00", "version": "v3" } } ``` ### 400 Invalid request - Phone number is missing or invalid #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Phone number is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380238+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API credentials ### 403 Forbidden ### 404 Phone number not found in provider or internal index #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_013", "message": "Phone number not found or invalid", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380251+00:00", "version": "v3" } } ``` ### 500 Internal server error - Contact support with request ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380258+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/numbers/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint.txt TITLE: Get phone number details ================================================================================ URL: https://docs.sent.dm/llms/reference/api/numbers/SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint.txt # GET /v3/numbers/lookup/{phoneNumber} Get phone number details Retrieves detailed information about a phone number including carrier, line type, porting status, and VoIP detection. Uses the customer's messaging provider for rich data, with fallback to the internal index. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3NumbersGetNumberEndpoint` **Tags:** Numbers ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `phoneNumber` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Phone number details returned successfully #### application/json ```typescript { success?: boolean, data?: { phone_number?: string, is_valid?: boolean, carrier_name?: string, line_type?: string, country_code?: string, mobile_country_code?: string, mobile_network_code?: string, is_ported?: boolean, is_voip?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "phone_number": "+12025551234", "is_valid": true, "carrier_name": "T-Mobile", "line_type": "mobile", "country_code": "US", "mobile_country_code": "310", "mobile_network_code": "260", "is_ported": false, "is_voip": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380147+00:00", "version": "v3" } } ``` ### 400 Invalid request - Phone number is missing or invalid #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Phone number is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380238+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid or missing API credentials ### 403 Forbidden ### 404 Phone number not found in provider or internal index #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_013", "message": "Phone number not found or invalid", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380251+00:00", "version": "v3" } } ``` ### 500 Internal server error - Contact support with request ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0380258+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint.txt TITLE: Complete profile setup ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint.txt # POST /v3/profiles/{profileId}/complete Complete profile setup Final step in 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 **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesCompleteProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | Profile ID from route | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Profile is already completed - returns current status #### application/json ```typescript unknown ``` ### 202 Profile completion started successfully - webhook will be called when finished #### application/json ```typescript unknown ``` ##### Example ```json { "success": true, "data": { "message": "Profile completion in progress" } } ``` ### 400 Invalid request - validation errors or prerequisites not met #### application/json ```typescript unknown ``` ### 401 Unauthorized - Valid profile-scoped API key required ### 403 Forbidden - API key does not have access to this profile ### 404 Profile not found #### application/json ```typescript unknown ``` ### 500 Internal server error occurred during validation #### application/json ```typescript unknown ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint.txt TITLE: Create a new profile ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint.txt # POST /v3/profiles Create a new profile Creates a new sender profile within an organization. Profiles represent different brands, departments, or use cases, each with their own messaging configuration and settings. Requires admin role in the organization. ## WhatsApp Business Account Every profile must be linked to a WhatsApp Business Account. There are two ways to do this: **1. Inherit from organization (default)** — Omit the `whatsapp_business_account` field. The profile will share the organization's WhatsApp Business Account, which must have been set up via WhatsApp Embedded Signup. This is the recommended path for most use cases. **2. Direct credentials** — Provide a `whatsapp_business_account` object with `waba_id`, `phone_number_id`, and `access_token`. Use this when the profile needs its own independent WhatsApp Business Account. Obtain these from Meta Business Manager by creating a System User with `whatsapp_business_messaging` and `whatsapp_business_management` permissions. If the `whatsapp_business_account` field is omitted and the organization has no WhatsApp Business Account configured, the request will be rejected with HTTP 422. ## Brand Include the optional `brand` field to create the brand for this profile at the same time. Cannot be used when `inherit_tcr_brand` is `true`. ## Payment Details When `billing_model` is `"profile"` or `"profile_and_organization"` you may include a `payment_details` object containing the card number, expiry (MM/YY), CVC, and billing ZIP code. Payment details are **never stored** on our servers and are forwarded directly to the payment processor. Providing `payment_details` when `billing_model` is `"organization"` is not allowed. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesCreateProfileEndpoint` **Tags:** Profiles ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 201 Profile created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, expected_messaging_volume?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Sales Team", "email": "team@acme.com", "icon": "https://example.com/sales-icon.png", "description": "Sales department sender profile", "short_name": "SALES", "status": "incomplete", "created_at": "2026-04-08T15:54:51.8537724+00:00", "updated_at": "2026-04-08T15:54:51.8538238+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": true, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "profile", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": "123456789012345", "billing_contact": { "name": "Acme Corp", "email": "billing@acmecorp.com", "phone": "+12025551234", "address": "123 Main Street, New York, NY 10001, US" }, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-04-08T15:54:51.8542023+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "expected_messaging_volume": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [ { "id": "US", "isMain": false } ], "notes": null } } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.8547217+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "name": [ "Profile name is required" ], "short_name": [ "short_name must be 3–11 characters, contain only letters, numbers, and spaces, and include at least one letter" ], "payment_details.expiry": [ "payment_details.expiry must be in MM/YY format (e.g. '09/27')" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.8547323+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.854733+00:00", "version": "v3" } } ``` ### 422 Organization has no WABA configured and no direct credentials provided #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Organization does not have a WhatsApp Business Account configured. Complete WhatsApp Embedded Signup for your organization first, or provide direct credentials in the 'whatsapp_business_account' field.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.8547336+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to create profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.8547361+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesDeleteProfileEndpoint.txt TITLE: Delete a profile ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesDeleteProfileEndpoint.txt # DELETE /v3/profiles/{profileId} Delete a profile Soft deletes a sender profile. The profile will be marked as deleted but data is retained. Requires admin role in the organization. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesDeleteProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 204 Profile deleted successfully ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_014", "message": "Profile not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.9492452+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to delete profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.9492486+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint.txt TITLE: Get profile by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint.txt # GET /v3/profiles/{profileId} Get profile by ID Retrieves detailed information about a specific sender profile within an organization, including brand and KYC information if a brand has been configured. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Profile retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, expected_messaging_volume?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Sales Team", "email": "team@acme.com", "icon": "https://example.com/sales-icon.png", "description": "Sales department sender profile", "short_name": "SALES", "status": "approved", "created_at": "2026-01-08T15:54:51.9563565+00:00", "updated_at": "2026-04-03T15:54:51.9563598+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": true, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "profile", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": null, "billing_contact": null, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-01-08T15:54:51.9563618+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "expected_messaging_volume": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [ { "id": "US", "isMain": false } ], "notes": null } } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.9563657+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_014", "message": "Profile not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.9563694+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to retrieve profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.9563704+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfilesEndpoint.txt TITLE: List profiles in organization ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfilesEndpoint.txt # GET /v3/profiles List profiles in organization Retrieves all sender profiles within an organization, including brand information for each profile. Profiles represent different brands, departments, or use cases within an organization, each with their own messaging configuration. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesGetProfilesEndpoint` **Tags:** Profiles ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Profiles retrieved successfully #### application/json ```typescript { success?: boolean, data?: { profiles?: Array<{ id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, expected_messaging_volume?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "profiles": [ { "id": "660e8400-e29b-41d4-a716-446655440001", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Marketing Team", "email": "team@acme.com", "icon": "https://example.com/marketing-icon.png", "description": "Marketing department sender profile", "short_name": "MKT", "status": "approved", "created_at": "2026-01-08T15:54:52.0083562+00:00", "updated_at": "2026-04-03T15:54:52.008364+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": false, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "profile", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": null, "billing_contact": null, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-01-08T15:54:52.0083692+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "expected_messaging_volume": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [], "notes": null } } } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0213703+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this organization ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0213845+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to retrieve profiles. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.021441+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint.txt TITLE: Update profile settings ================================================================================ URL: https://docs.sent.dm/llms/reference/api/profiles/SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint.txt # PATCH /v3/profiles/{profileId} Update profile settings Updates a profile's configuration and settings. Requires admin role in the organization. Only provided fields will be updated (partial update). ## Brand Management Include the optional `brand` field to create or update the brand associated with this profile. The brand holds KYC and TCR compliance data (legal business info, contact details, messaging vertical). Once a brand has been submitted to TCR it cannot be modified. Setting `inherit_tcr_brand: true` and providing `brand` in the same request is not allowed. ## Payment Details When `billing_model` is `"profile"` or `"profile_and_organization"` you may include a `payment_details` object containing the card number, expiry (MM/YY), CVC, and billing ZIP code. Payment details are **never stored** on our servers and are forwarded directly to the payment processor. Providing `payment_details` when `billing_model` is `"organization"` is not allowed. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3ProfilesUpdateProfileEndpoint` **Tags:** Profiles ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `profileId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Profile updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, organization_id?: string, name?: string, email?: string, icon?: string, description?: string, short_name?: string, status?: string, created_at?: string, updated_at?: string, allow_contact_sharing?: boolean, allow_template_sharing?: boolean, inherit_contacts?: boolean, inherit_templates?: boolean, inherit_tcr_brand?: boolean, inherit_tcr_campaign?: boolean, billing_model?: string, sending_phone_number_profile_id?: string, sending_whatsapp_number_profile_id?: string, sending_phone_number?: string, whatsapp_phone_number?: string, allow_number_change_during_onboarding?: boolean, waba_id?: string, billing_contact?: { name?: string, email?: string, phone?: string, address?: string }, brand?: { id?: string, tcr_brand_id?: string, status?: { }, identity_status?: { }, universal_ein?: string, csp_id?: string, submitted_to_tcr?: boolean, submitted_at?: string, is_inherited?: boolean, created_at?: string, updated_at?: string, contact?: { name?: string, business_name?: string, role?: string, phone?: string, email?: string, phone_country_code?: string }, business?: { legal_name?: string, tax_id?: string, tax_id_type?: string, entity_type?: string, street?: string, city?: string, state?: string, postal_code?: string, country?: string, url?: string, country_of_registration?: string }, compliance?: { vertical?: { }, brand_relationship?: { }, primary_use_case?: string, expected_messaging_volume?: string, is_tcr_application?: boolean, phone_number_prefix?: string, destination_countries?: Array<{ id?: string, isMain?: boolean }>, notes?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "organization_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Sales Team - Updated", "email": "team@acme.com", "icon": "https://example.com/sales-icon.png", "description": "Updated sales department sender profile", "short_name": "SALES", "status": "approved", "created_at": "2026-01-08T15:54:52.0314486+00:00", "updated_at": "2026-04-08T15:54:52.0314528+00:00", "allow_contact_sharing": true, "allow_template_sharing": false, "inherit_contacts": true, "inherit_templates": true, "inherit_tcr_brand": false, "inherit_tcr_campaign": false, "billing_model": "organization", "sending_phone_number_profile_id": null, "sending_whatsapp_number_profile_id": null, "sending_phone_number": null, "whatsapp_phone_number": null, "allow_number_change_during_onboarding": null, "waba_id": null, "billing_contact": null, "brand": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tcr_brand_id": null, "status": null, "identity_status": null, "universal_ein": null, "csp_id": null, "submitted_to_tcr": false, "submitted_at": null, "is_inherited": false, "created_at": "2026-04-08T15:54:52.0314548+00:00", "updated_at": null, "contact": { "name": "John Smith", "business_name": "Acme Corp", "role": null, "phone": null, "email": "john@acmecorp.com", "phone_country_code": null }, "business": { "legal_name": "Acme Corporation LLC", "tax_id": null, "tax_id_type": null, "entity_type": null, "street": null, "city": null, "state": null, "postal_code": null, "country": "US", "url": null, "country_of_registration": null }, "compliance": { "vertical": "PROFESSIONAL", "brand_relationship": "SMALL_ACCOUNT", "primary_use_case": null, "expected_messaging_volume": null, "is_tcr_application": true, "phone_number_prefix": null, "destination_countries": [ { "id": "US", "isMain": false } ], "notes": null } } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0314644+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "brand": [ "Cannot provide brand data when inherit_tcr_brand is true." ], "payment_details.expiry": [ "payment_details.expiry must be in MM/YY format (e.g. '09/27')" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0314782+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this profile ### 404 Profile not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_014", "message": "Profile not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0314794+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "Failed to update profile. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:52.0314802+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/rate-limits.txt TITLE: Rate Limits ================================================================================ URL: https://docs.sent.dm/llms/reference/api/rate-limits.txt Understanding API rate limiting, quotas, and best practices for managing request volumes in the Sent API v3 # Rate Limits The Sent API v3 implements rate limiting to ensure platform stability and fair access for all users. Rate limits are applied per customer account based on the API key used. --- ## Rate Limit Tiers ### Standard Endpoints Most API endpoints have the following limits: | Tier | Requests per Minute | Burst Capacity | |------|---------------------|----------------| | Standard | 200 | 50 | **Standard endpoints include:** - Contacts (GET, POST, PATCH, DELETE) - Messages (GET, POST) - Templates (GET, POST, PUT, DELETE) - Profiles (GET, POST, PATCH, DELETE) - Brands and Campaigns (GET, POST, PUT, DELETE) ### Sensitive Endpoints Certain endpoints that perform sensitive operations have stricter limits: | Tier | Requests per Minute | Burst Capacity | |------|---------------------|----------------| | Sensitive | 10 | 5 | **Sensitive endpoints include:** - Webhook secret rotation (`POST /v3/webhooks/{id}/rotate-secret`) - User invitation (`POST /v3/users`) - Profile completion (`POST /v3/profiles/{profileId}/complete`) ### Message Sending Message sending endpoints have specific limits based on your account tier: | Tier | Messages per Minute | Notes | |------|---------------------|-------| | Starter | 60 | For new accounts and testing | | Growth | 300 | Standard production limits | | Enterprise | Custom | Contact support for higher limits | --- ## Rate Limit Headers All API responses include rate limit headers. When you exceed a rate limit, the `429 Too Many Requests` response includes additional headers: ### Standard Response Headers ```http X-RateLimit-Limit: 200 X-RateLimit-Remaining: 150 X-RateLimit-Reset: 1705312800 ``` ### Rate Limit Exceeded Response (429) ```http Retry-After: 60 X-RateLimit-Limit: 200 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1705312800 ``` ### Header Reference | Header | Description | Example | |--------|-------------|---------| | `X-RateLimit-Limit` | Maximum requests allowed in the window | `200` | | `X-RateLimit-Remaining` | Requests remaining in current window | `150` | | `X-RateLimit-Reset` | Unix timestamp when the window resets | `1705312800` | | `Retry-After` | Seconds until you can retry (only on 429) | `60` | --- ## Handling Rate Limits ### 429 Response Body When rate limited, the API returns: ```json { "success": false, "error": { "code": "BUSINESS_002", "message": "Rate limit exceeded", "doc_url": "https://docs.sent.dm/errors/BUSINESS_002" }, "meta": { "request_id": "req_abc123", "timestamp": "2024-01-15T10:30:00Z", "version": "v3" } } ``` --- ## Best Practices ### Implement Exponential Backoff ```typescript async function makeRequestWithRetry( url: string, options: RequestInit, maxRetries = 3 ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch(url, options); if (response.status !== 429) { return response; } if (attempt === maxRetries) { throw new Error('Max retries exceeded'); } // Get retry delay from header or use exponential backoff const retryAfter = response.headers.get('Retry-After'); const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000; console.log(`Rate limited. Retrying after ${delay}ms...`); await sleep(delay); } throw new Error('Unreachable'); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python import time import requests from typing import Optional def make_request_with_retry( method: str, url: str, headers: dict, json_data: Optional[dict] = None, max_retries: int = 3 ) -> requests.Response: """Make request with exponential backoff on 429.""" for attempt in range(max_retries + 1): response = requests.request( method, url, headers=headers, json=json_data ) if response.status_code != 429: return response if attempt == max_retries: raise Exception('Max retries exceeded') # Get retry delay from header or use exponential backoff retry_after = response.headers.get('Retry-After') delay = int(retry_after) if retry_after else (2 ** attempt) print(f'Rate limited. Retrying after {delay}s...') time.sleep(delay) raise Exception('Unreachable') ``` ```go package main import ( "fmt" "math" "net/http" "strconv" "time" ) func makeRequestWithRetry( req *http.Request, maxRetries int, ) (*http.Response, error) { client := &http.Client{} for attempt := 0; attempt <= maxRetries; attempt++ { resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode != 429 { return resp, nil } if attempt == maxRetries { return nil, fmt.Errorf("max retries exceeded") } // Get retry delay from header or use exponential backoff retryAfter := resp.Header.Get("Retry-After") delay := math.Pow(2, float64(attempt)) if retryAfter != "" { if seconds, err := strconv.Atoi(retryAfter); err == nil { delay = float64(seconds) } } fmt.Printf("Rate limited. Retrying after %.0fs...\n", delay) time.Sleep(time.Duration(delay) * time.Second) } return nil, fmt.Errorf("unreachable") } ``` --- ### Monitor Rate Limit Headers ```typescript interface RateLimitStatus { limit: number; remaining: number; reset: Date; } async function makeRequest(url: string, options: RequestInit): Promise { const response = await fetch(url, options); // Log rate limit status for monitoring const limit = response.headers.get('X-RateLimit-Limit'); const remaining = response.headers.get('X-RateLimit-Remaining'); const reset = response.headers.get('X-RateLimit-Reset'); if (limit && remaining && reset) { console.log(`Rate limit: ${remaining}/${limit}, resets at ${new Date(parseInt(reset) * 1000)}`); // Alert when running low if (parseInt(remaining) < 20) { console.warn('Rate limit running low!'); } // Store for metrics trackRateLimit({ limit: parseInt(limit), remaining: parseInt(remaining), reset: new Date(parseInt(reset) * 1000) }); } return response; } function trackRateLimit(status: RateLimitStatus): void { // Send to your monitoring system // metrics.gauge('api.rate_limit.remaining', status.remaining); } ``` ```python import time from dataclasses import dataclass from typing import Optional import requests @dataclass class RateLimitStatus: limit: int remaining: int reset: int # Unix timestamp def make_request(url: str, headers: dict) -> requests.Response: """Make request and track rate limit headers.""" response = requests.get(url, headers=headers) # Log rate limit status for monitoring limit = response.headers.get('X-RateLimit-Limit') remaining = response.headers.get('X-RateLimit-Remaining') reset = response.headers.get('X-RateLimit-Reset') if limit and remaining and reset: reset_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(reset))) print(f'Rate limit: {remaining}/{limit}, resets at {reset_time}') # Alert when running low if int(remaining) < 20: print('WARNING: Rate limit running low!') # Store for metrics track_rate_limit(RateLimitStatus( limit=int(limit), remaining=int(remaining), reset=int(reset) )) return response def track_rate_limit(status: RateLimitStatus): """Send to your monitoring system (Datadog, Prometheus, etc).""" # Example: statsd.gauge('api.rate_limit.remaining', status.remaining) pass ``` ```go package main import ( "fmt" "net/http" "strconv" "time" ) type RateLimitStatus struct { Limit int Remaining int Reset time.Time } func makeRequest(req *http.Request) (*http.Response, error) { client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } // Log rate limit status for monitoring limit := resp.Header.Get("X-RateLimit-Limit") remaining := resp.Header.Get("X-RateLimit-Remaining") reset := resp.Header.Get("X-RateLimit-Reset") if limit != "" && remaining != "" && reset != "" { limitInt, _ := strconv.Atoi(limit) remainingInt, _ := strconv.Atoi(remaining) resetUnix, _ := strconv.ParseInt(reset, 10, 64) resetTime := time.Unix(resetUnix, 0) fmt.Printf("Rate limit: %d/%d, resets at %s\n", remainingInt, limitInt, resetTime) // Alert when running low if remainingInt < 20 { fmt.Println("WARNING: Rate limit running low!") } // Store for metrics trackRateLimit(RateLimitStatus{ Limit: limitInt, Remaining: remainingInt, Reset: resetTime, }) } return resp, nil } func trackRateLimit(status RateLimitStatus) { // Send to your monitoring system // Example: statsd.Gauge("api.rate_limit.remaining", status.Remaining) } ``` --- ### Implement Request Throttling ```typescript class RateLimiter { private minInterval: number; private lastRequestTime: number = 0; constructor(requestsPerSecond: number) { this.minInterval = 1000 / requestsPerSecond; } async throttle(): Promise { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < this.minInterval) { const delay = this.minInterval - timeSinceLastRequest; await sleep(delay); } this.lastRequestTime = Date.now(); } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } // Use: limit to 3 requests per second (180/min, well under the 200 limit) const limiter = new RateLimiter(3); async function makeThrottledRequest(url: string, options: RequestInit): Promise { await limiter.throttle(); return fetch(url, options); } ``` ```python import time import requests from typing import Optional class RateLimiter: """Simple rate limiter using token bucket algorithm.""" def __init__(self, requests_per_second: float): self.min_interval = 1.0 / requests_per_second self.last_request_time: Optional[float] = None def throttle(self): """Wait if necessary to maintain rate limit.""" if self.last_request_time is None: self.last_request_time = time.time() return now = time.time() time_since_last = now - self.last_request_time if time_since_last < self.min_interval: delay = self.min_interval - time_since_last time.sleep(delay) self.last_request_time = time.time() # Use: limit to 3 requests per second (180/min, well under the 200 limit) limiter = RateLimiter(3) def make_throttled_request(url: str, headers: dict) -> requests.Response: limiter.throttle() return requests.get(url, headers=headers) ``` ```go package main import ( "net/http" "sync" "time" ) type RateLimiter struct { minInterval time.Duration lastRequestTime time.Time mu sync.Mutex } func NewRateLimiter(requestsPerSecond float64) *RateLimiter { return &RateLimiter{ minInterval: time.Duration(float64(time.Second) / requestsPerSecond), } } func (r *RateLimiter) Throttle() { r.mu.Lock() defer r.mu.Unlock() if r.lastRequestTime.IsZero() { r.lastRequestTime = time.Now() return } timeSinceLast := time.Since(r.lastRequestTime) if timeSinceLast < r.minInterval { time.Sleep(r.minInterval - timeSinceLast) } r.lastRequestTime = time.Now() } // Use: limit to 3 requests per second (180/min, well under the 200 limit) var limiter = NewRateLimiter(3) func makeThrottledRequest(req *http.Request) (*http.Response, error) { limiter.Throttle() client := &http.Client{} return client.Do(req) } ``` --- ### Use Request Batching When possible, batch operations to reduce API calls: ```typescript // ❌ Inefficient: Individual requests (100 API calls) for (const contact of contacts) { await createContact(contact); } // ✅ Efficient: Client-side caching to reduce duplicates class CachedContactClient { private cache = new Map(); async getContact(id: string): Promise { // Return cached result if available if (this.cache.has(id)) { return this.cache.get(id)!; } const contact = await fetchContact(id); this.cache.set(id, contact); return contact; } async batchProcessContacts( contacts: Contact[], batchSize: number = 10 ): Promise { // Process in batches with rate limiting for (let i = 0; i < contacts.length; i += batchSize) { const batch = contacts.slice(i, i + batchSize); // Process batch concurrently await Promise.all( batch.map(contact => this.processContact(contact)) ); // Wait between batches if (i + batchSize < contacts.length) { await sleep(1000); } } } private async processContact(contact: Contact): Promise { // Implementation } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } ``` ```python from typing import Dict, List, Optional import time import requests # ❌ Inefficient: Individual requests (100 API calls) # for contact in contacts: # create_contact(contact) # ✅ Efficient: Client-side caching to reduce duplicates class CachedContactClient: def __init__(self, api_key: str): self.api_key = api_key self.cache: Dict[str, dict] = {} def get_contact(self, contact_id: str) -> dict: """Get contact with caching.""" # Return cached result if available if contact_id in self.cache: return self.cache[contact_id] response = requests.get( f'https://api.sent.dm/v3/contacts/{contact_id}', headers={'x-api-key': self.api_key} ) contact = response.json()['data'] self.cache[contact_id] = contact return contact def batch_process_contacts( self, contacts: List[dict], batch_size: int = 10 ): """Process contacts in batches with rate limiting.""" for i in range(0, len(contacts), batch_size): batch = contacts[i:i + batch_size] # Process batch for contact in batch: self.process_contact(contact) # Wait between batches (except after last batch) if i + batch_size < len(contacts): time.sleep(1) def process_contact(self, contact: dict): """Process a single contact.""" # Implementation pass ``` ```go package main import ( "sync" "time" ) // ❌ Inefficient: Individual requests (100 API calls) // for _, contact := range contacts { // createContact(contact) // } // ✅ Efficient: Client-side caching to reduce duplicates type CachedContactClient struct { apiKey string cache map[string]Contact mu sync.RWMutex } func NewCachedContactClient(apiKey string) *CachedContactClient { return &CachedContactClient{ apiKey: apiKey, cache: make(map[string]Contact), } } func (c *CachedContactClient) GetContact(id string) (Contact, error) { // Return cached result if available c.mu.RLock() if contact, ok := c.cache[id]; ok { c.mu.RUnlock() return contact, nil } c.mu.RUnlock() // Fetch and cache contact, err := fetchContact(id, c.apiKey) if err != nil { return Contact{}, err } c.mu.Lock() c.cache[id] = contact c.mu.Unlock() return contact, nil } func (c *CachedContactClient) BatchProcessContacts( contacts []Contact, batchSize int, ) error { if batchSize == 0 { batchSize = 10 } for i := 0; i < len(contacts); i += batchSize { end := i + batchSize if end > len(contacts) { end = len(contacts) } batch := contacts[i:end] // Process batch concurrently var wg sync.WaitGroup for _, contact := range batch { wg.Add(1) go func(c Contact) { defer wg.Done() processContact(c) }(contact) } wg.Wait() // Wait between batches if end < len(contacts) { time.Sleep(time.Second) } } return nil } func processContact(contact Contact) error { // Implementation return nil } type Contact struct { ID string PhoneNumber string } func fetchContact(id, apiKey string) (Contact, error) { // Implementation return Contact{}, nil } ``` --- ## Rate Limiting by Endpoint ### Contacts | Endpoint | Method | Rate Limit | |----------|--------|------------| | `/v3/contacts` | GET | 200/min | | `/v3/contacts` | POST | 200/min | | `/v3/contacts/{id}` | GET | 200/min | | `/v3/contacts/{id}` | PATCH | 200/min | | `/v3/contacts/{id}` | DELETE | 200/min | ### Messages | Endpoint | Method | Rate Limit | |----------|--------|------------| | `/v3/messages` | POST | 60-300/min* | | `/v3/messages/{id}` | GET | 200/min | | `/v3/messages/{id}/activities` | GET | 200/min | *Based on your account tier ### Templates | Endpoint | Method | Rate Limit | |----------|--------|------------| | `/v3/templates` | GET | 200/min | | `/v3/templates` | POST | 200/min | | `/v3/templates/{id}` | GET | 200/min | | `/v3/templates/{id}` | PUT | 200/min | | `/v3/templates/{id}` | DELETE | 200/min | ### Webhooks | Endpoint | Method | Rate Limit | |----------|--------|------------| | `/v3/webhooks` | GET | 200/min | | `/v3/webhooks` | POST | 200/min | | `/v3/webhooks/{id}/rotate-secret` | POST | 10/min | | `/v3/webhooks/{id}/test` | POST | 60/min | ### Users | Endpoint | Method | Rate Limit | |----------|--------|------------| | `/v3/users` | GET | 200/min | | `/v3/users` | POST | 10/min | | `/v3/users/{id}` | PATCH | 200/min | | `/v3/users/{id}` | DELETE | 200/min | --- ## Increasing Rate Limits If you're consistently hitting rate limits, consider: ### Optimize Your Integration - Implement caching to reduce duplicate requests - Use webhooks instead of polling for status updates - Batch operations where possible ### Contact Support For legitimate high-volume use cases, contact [support@sent.dm](mailto:support@sent.dm) to discuss: - Increased rate limits - Enterprise pricing - Dedicated infrastructure When contacting support, provide: - Your use case and expected volume - Current rate limit issues you're experiencing - Timeframe for increased needs --- ## Rate Limiting FAQ ### Do rate limits apply per API key or per account? Rate limits apply per customer account. All API keys for the same account share the same rate limit pool. ### Do failed requests count against rate limits? Authentication failures (401, 403) do not count against rate limits. All other requests, including validation errors (400, 422), count toward your limit. ### What happens when I exceed a rate limit? You receive a `429 Too Many Requests` response with a `Retry-After` header indicating when you can retry. ### Can I check my current rate limit status? Yes, check the `X-RateLimit-*` headers on any API response. ### Do rate limits reset at specific times? Rate limits use a sliding window. The `X-RateLimit-Reset` header indicates when your current window expires. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint.txt TITLE: Create a new template ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint.txt # POST /v3/templates Create a new template Creates a new message template with header, body, footer, and buttons. The template can be submitted for review immediately or saved as draft for later submission. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesCreateTemplateEndpoint` **Tags:** Templates ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 201 Template created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Welcome Message", "category": "MARKETING", "language": "en_US", "status": "DRAFT", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-04-08T15:54:51.4067804+00:00", "updated_at": "2026-04-08T15:54:51.4068161+00:00", "is_published": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4069021+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Template definition is required", "details": { "definition": [ "Template definition is required" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4069119+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while creating the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4069128+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint.txt TITLE: Delete a template ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint.txt # DELETE /v3/templates/{id} Delete a template Deletes a template by ID. Optionally, you can also delete the template from WhatsApp/Meta by setting delete_from_meta=true. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesDeleteTemplateEndpoint` **Tags:** Templates ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Template ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 204 Template deleted successfully ### 400 Invalid template ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid template ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4263633+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4263695+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while deleting the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4263711+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint.txt TITLE: Get template by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint.txt # GET /v3/templates/{id} Get template by ID Retrieves a specific template by its ID. Returns template details including name, category, language, status, and definition. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplateEndpoint` **Tags:** Templates ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Template ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Template retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Welcome Message", "category": "MARKETING", "language": "en_US", "status": "APPROVED", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-03-09T15:54:51.4369539+00:00", "updated_at": "2026-03-24T15:54:51.4369573+00:00", "is_published": true }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4369587+00:00", "version": "v3" } } ``` ### 400 Invalid template ID format #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid template ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4369649+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4369659+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4369669+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint.txt TITLE: Get templates list ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint.txt # GET /v3/templates Get templates list Retrieves a paginated list of message templates for the authenticated customer. Supports filtering by status, category, and search term. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesGetTemplatesEndpoint` **Tags:** Templates ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | Page number (1-indexed) | | `page_size` | `integer` | true | Number of items per page | | `search` | `string` | false | Optional search term for filtering templates | | `status` | `string` | false | Optional status filter: APPROVED, PENDING, REJECTED | | `category` | `string` | false | Optional category filter: MARKETING, UTILITY, AUTHENTICATION | | `is_welcome_playground` | `boolean` | false | Optional filter by welcome playground flag | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Templates retrieved successfully #### application/json ```typescript { success?: boolean, data?: { templates?: Array<{ id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }>, pagination?: unknown }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "templates": [ { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Welcome Message", "category": "MARKETING", "language": "en_US", "status": "APPROVED", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-03-09T15:54:51.4421511+00:00", "updated_at": "2026-03-24T15:54:51.4421536+00:00", "is_published": true } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4422706+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "page_size": [ "Page size must be between 1 and 100" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4422786+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving templates", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4422796+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint.txt TITLE: Update a template ================================================================================ URL: https://docs.sent.dm/llms/reference/api/templates/SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint.txt # PUT /v3/templates/{id} Update a template Updates an existing template's name, category, language, definition, or submits it for review. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3TemplatesUpdateTemplateEndpoint` **Tags:** Templates ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | Template ID from route parameter | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Template updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, name?: string, category?: string, language?: string, status?: string, channels?: Array, variables?: Array, created_at?: string, updated_at?: string, is_published?: boolean }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "Updated Welcome Message", "category": "MARKETING", "language": "en_US", "status": "DRAFT", "channels": [ "sms", "whatsapp" ], "variables": [ "name", "company" ], "created_at": "2026-03-09T15:54:51.4474954+00:00", "updated_at": "2026-04-08T15:54:51.4474981+00:00", "is_published": false }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4474992+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Category must be one of: MARKETING, UTILITY, AUTHENTICATION", "details": { "category": [ "Category must be one of: MARKETING, UTILITY, AUTHENTICATION" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4475061+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Template not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_002", "message": "Template not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4475069+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while updating the template", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.4475077+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/test-mode.txt TITLE: Sandbox Mode ================================================================================ URL: https://docs.sent.dm/llms/reference/api/test-mode.txt Validate your integration without side effects using sandbox mode — develop, debug, and deploy with confidence # Sandbox Mode **Build fearlessly. Test recklessly. Deploy confidently.** Sandbox mode is your safety net — a way to validate every aspect of your integration without sending real messages, consuming credits, or touching production data. It's like having a perfectly realistic simulation of the Sent API that never leaves your sandbox. **The Golden Rule:** Add `sandbox: true` to any mutation request. The API validates everything and returns a realistic response — but nothing actually happens. --- ## Why Sandbox Mode Changes Everything | Without Sandbox Mode | With Sandbox Mode | |-------------------|----------------| | 😰 Burn credits on every test | 😎 Zero credit consumption | | 📱 Accidentally message real users | 🧪 Fake responses, zero side effects | | 🔧 Guess if your payload is valid | ✅ Real validation, instant feedback | | 🚀 Hope it works in production | 🎯 Know it works before you ship | --- ## How It Works When you include `sandbox: true` in your request: 1. **Authentication runs** — Invalid API keys are still rejected 2. **Validation runs** — Malformed payloads return real errors 3. **Permissions checked** — Authorization rules still apply 4. **Response returned** — A realistic fake response with sample data 5. **Nothing happens** — No messages sent, no database writes, no external calls ```http POST /v3/messages Content-Type: application/json x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx { "sandbox": true, "to": ["+1234567890"], "template": { "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "parameters": { "customer_name": "Test User" } } } ``` **Response:** ```http HTTP/1.1 202 Accepted X-Sandbox: true X-Request-Id: req_test_abc123 Content-Type: application/json { "success": true, "data": { "status": "QUEUED", "template_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "template_name": "order_confirmation", "recipients": [ { "message_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "to": "+1234567890", "channel": "sms", "body": "Hi Test User, your order has been confirmed." } ] }, "error": null, "meta": { "request_id": "req_test_abc123", "timestamp": "2024-01-15T10:30:00Z", "version": "v3" } } ``` Notice the `X-Sandbox: true` header — your confirmation that this was a sandbox request. --- ## Perfect for These Scenarios ### Unit Testing Test your integration without mocking the entire API: ```typescript // Your test suite async function testMessageSending() { const response = await sentApi.sendMessage({ sandbox: true, // Zero side effects phone_number: "+1234567890", template_id: "welcome-template", variables: { name: "Test" } }); // Assert against real response format expect(response.success).toBe(true); expect(response.data.status).toBe("pending"); expect(response.meta.request_id).toBeDefined(); } ``` ```python # Your test suite def test_message_sending(): response = sent_api.send_message( sandbox=True, # Zero side effects phone_number="+1234567890", template_id="welcome-template", variables={"name": "Test"} ) # Assert against real response format assert response['success'] is True assert response['data']['status'] == "pending" assert 'request_id' in response['meta'] ``` ```go // Your test suite func TestMessageSending(t *testing.T) { response, err := sentAPI.SendMessage(SendMessageRequest{ Sandbox: true, // Zero side effects PhoneNumber: "+1234567890", TemplateID: "welcome-template", Variables: map[string]string{"name": "Test"}, }) // Assert against real response format assert.True(t, response.Success) assert.Equal(t, "pending", response.Data.Status) assert.NotEmpty(t, response.Meta.RequestID) } ``` ### CI/CD Pipelines Run integration tests in your pipeline without burning credits: ```yaml # .github/workflows/test.yml - name: Integration Tests env: SENT_API_KEY: ${{ secrets.SENT_API_KEY }} run: | # All tests run in test mode — zero cost npm run test:integration ``` ### Debugging Production Issues Reproduce a production error without risking more issues: ```python # Reproduce the exact request that failed response = client.request('POST', '/v3/messages', { 'sandbox': True, # Safe reproduction 'phone_number': problem_number, 'template_id': problem_template, 'variables': problem_variables }) # Inspect the full response without side effects print(f"Validation result: {response}") ``` ### Interactive Development Experiment freely while building your integration: ```bash # Try different payloads without consequences curl -X POST https://api.sent.dm/v3/contacts \ -H "x-api-key: $SENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sandbox": true, "phone_number": "+15555555555" }' ``` --- ## Supported Endpoints Sandbox mode works on all mutation endpoints: | Endpoint | Sandbox Behavior | |----------|------------------| | `POST /v3/messages` | Returns fake message object, no SMS/WhatsApp sent | | `POST /v3/contacts` | Returns fake contact, no database write | | `PATCH /v3/contacts/{id}` | Returns fake updated contact | | `DELETE /v3/contacts/{id}` | Returns 204, contact not deleted | | `POST /v3/templates` | Returns fake template with "PENDING" status | | `PUT /v3/templates/{id}` | Returns fake updated template | | `DELETE /v3/templates/{id}` | Returns 204, template not deleted | | `POST /v3/webhooks` | Returns fake webhook with random secret | | `POST /v3/profiles` | Returns fake profile | | `POST /v3/brands` | Returns fake brand with TCR simulation | --- ## Sandbox Mode vs Production ```typescript class SentClient { private apiKey: string; private baseUrl = 'https://api.sent.dm'; constructor(apiKey: string) { this.apiKey = apiKey; } async sendMessage( payload: MessagePayload, options: { sandbox?: boolean } = {} ): Promise { const response = await fetch(`${this.baseUrl}/v3/messages`, { method: 'POST', headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, sandbox: options.sandbox ?? false }) }); return response.json(); } } // Usage const client = new SentClient(process.env.SENT_API_KEY!); // Development — safe, no side effects await client.sendMessage({ phone_number: '+1234567890', template_id: 'welcome-template', variables: { name: 'Test' } }, { sandbox: true }); // Production — the real deal await client.sendMessage({ phone_number: '+1234567890', template_id: 'welcome-template', variables: { name: 'Real User' } }, { sandbox: false }); ``` ```python import os import requests from typing import Optional, Dict, Any class SentClient: def __init__(self, api_key: Optional[str] = None): self.api_key = api_key or os.environ['SENT_API_KEY'] self.base_url = 'https://api.sent.dm' def send_message( self, phone_number: str, template_id: str, variables: Optional[Dict[str, Any]] = None, sandbox: bool = False ) -> Dict[str, Any]: """Send a message with optional sandbox mode.""" response = requests.post( f'{self.base_url}/v3/messages', headers={ 'x-api-key': self.api_key, 'Content-Type': 'application/json' }, json={ 'phone_number': phone_number, 'template_id': template_id, 'variables': variables or {}, 'sandbox': sandbox # Safe testing when True } ) return response.json() # Usage client = SentClient() # Development — safe, no side effects client.send_message( '+1234567890', 'welcome-template', {'name': 'Test'}, sandbox=True ) # Production — the real deal client.send_message( '+1234567890', 'welcome-template', {'name': 'Real User'}, sandbox=False ) ``` ```go package main import ( "bytes" "encoding/json" "net/http" "os" ) type SentClient struct { APIKey string BaseURL string } func NewClient() *SentClient { return &SentClient{ APIKey: os.Getenv("SENT_API_KEY"), BaseURL: "https://api.sent.dm", } } func (c *SentClient) SendMessage( phone string, templateID string, variables map[string]string, sandbox bool, ) (map[string]interface{}, error) { payload := map[string]interface{}{ "phone_number": phone, "template_id": templateID, "variables": variables, "sandbox": sandbox, // Safe testing when true } body, _ := json.Marshal(payload) req, _ := http.NewRequest( "POST", c.BaseURL+"/v3/messages", bytes.NewBuffer(body), ) req.Header.Set("x-api-key", c.APIKey) req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) return result, nil } // Usage func main() { client := NewClient() // Development — safe, no side effects client.SendMessage( "+1234567890", "welcome-template", map[string]string{"name": "Test"}, true, // sandbox ) // Production — the real deal client.SendMessage( "+1234567890", "welcome-template", map[string]string{"name": "Real User"}, false, // sandbox ) } ``` --- ## Best Practices ### Always Test First ```typescript // Good: Test in development, then deploy async function deployToProduction() { // Step 1: Validate in sandbox mode const sandboxResult = await sendMessage(payload, { sandbox: true }); if (!sandboxResult.success) { throw new Error('Validation failed'); } // Step 2: Deploy with confidence return await sendMessage(payload, { sandbox: false }); } ``` ```python # Good: Test in development, then deploy def deploy_to_production(payload): # Step 1: Validate in sandbox mode sandbox_result = send_message(payload, sandbox=True) if not sandbox_result.get('success'): raise Exception('Validation failed') # Step 2: Deploy with confidence return send_message(payload, sandbox=False) ``` ```go // Good: Test in development, then deploy func deployToProduction(payload MessagePayload) (*APIResponse, error) { // Step 1: Validate in sandbox mode sandboxResult, err := sendMessage(payload, true) if err != nil || !sandboxResult.Success { return nil, fmt.Errorf("validation failed") } // Step 2: Deploy with confidence return sendMessage(payload, false) } ``` ### Use in CI/CD ```yaml # Test job — runs in sandbox mode - name: Run Integration Tests run: | export SENT_API_KEY=${{ secrets.SENT_API_KEY }} pytest tests/integration --sandbox # Deploy job — only after tests pass - name: Deploy to Production if: github.ref == 'refs/heads/main' run: ./deploy.sh ``` ### Debug Without Fear ```typescript // Reproduce production issues safely async function debugFailedMessage(originalPayload: MessagePayload) { // Add sandbox mode to debug without side effects const debugPayload = { ...originalPayload, sandbox: true }; const response = await api.post('/v3/messages', debugPayload); // Inspect full response logger.debug('Debug response:', response); return response; } ``` ```python # Reproduce production issues safely def debug_failed_message(original_payload): # Add sandbox mode to debug without side effects debug_payload = {**original_payload, 'sandbox': True} response = api.post('/v3/messages', json=debug_payload) # Inspect full response logger.debug(f"Debug response: {response}") return response ``` ```go // Reproduce production issues safely func debugFailedMessage(originalPayload map[string]interface{}) (map[string]interface{}, error) { // Add sandbox mode to debug without side effects debugPayload := make(map[string]interface{}) for k, v := range originalPayload { debugPayload[k] = v } debugPayload["sandbox"] = true response, err := api.Post("/v3/messages", debugPayload) if err != nil { return nil, err } // Inspect full response log.Printf("Debug response: %+v", response) return response, nil } ``` ### Environment-Based Toggle ```typescript // Automatically use sandbox mode in development const isProduction = process.env.NODE_ENV === 'production'; const client = new SentClient({ apiKey: process.env.SENT_API_KEY!, sandbox: !isProduction // Auto-enable in dev/staging }); ``` ```python import os # Automatically use sandbox mode in development is_production = os.environ.get('ENVIRONMENT') == 'production' client = SentClient( api_key=os.environ.get('SENT_API_KEY'), sandbox=not is_production # Auto-enable in dev/staging ) ``` ```go import "os" // Automatically use sandbox mode in development isProduction := os.Getenv("ENVIRONMENT") == "production" client := NewSentClient(SentClientOptions{ APIKey: os.Getenv("SENT_API_KEY"), Sandbox: !isProduction, // Auto-enable in dev/staging }) ``` --- ## Common Patterns ### Feature Flags ```typescript // Gradual rollout with sandbox mode async function sendWithFeatureFlag(payload: MessagePayload) { const featureEnabled = await checkFeatureFlag('new-messaging'); if (!featureEnabled) { // Sandbox mode fallback — validate without sending return await api.sendMessage({ ...payload, sandbox: true }); } // Full production send return await api.sendMessage(payload); } ``` ```python # Gradual rollout with sandbox mode def send_with_feature_flag(payload): feature_enabled = check_feature_flag('new-messaging') if not feature_enabled: # Sandbox mode fallback — validate without sending return api.send_message({**payload, 'sandbox': True}) # Full production send return api.send_message(payload) ``` ```go // Gradual rollout with sandbox mode func sendWithFeatureFlag(payload map[string]interface{}) (*APIResponse, error) { featureEnabled := checkFeatureFlag("new-messaging") if !featureEnabled { // Sandbox mode fallback — validate without sending payload["sandbox"] = true return api.SendMessage(payload) } // Full production send return api.SendMessage(payload) } ``` ### Load Testing ```typescript // Load test without burning credits async function loadTest(): Promise { const tasks: Promise[] = []; for (let i = 0; i < 1000; i++) { const task = api.sendMessage({ phone_number: '+1234567890', template_id: 'test-template', variables: { index: i }, sandbox: true // Zero cost load test }); tasks.push(task); } const results = await Promise.all(tasks); return analyzeResults(results); } ``` ```python # Load test without burning credits import asyncio async def load_test(): tasks = [] for i in range(1000): task = api.send_message( '+1234567890', 'test-template', {'index': i}, sandbox=True # Zero cost load test ) tasks.append(task) results = await asyncio.gather(*tasks) return analyze_results(results) ``` ```go // Load test without burning credits func loadTest() ([]APIResponse, error) { var wg sync.WaitGroup results := make(chan APIResponse, 1000) for i := 0; i < 1000; i++ { wg.Add(1) go func(index int) { defer wg.Done() resp, _ := api.SendMessage(map[string]interface{}{ "phone_number": "+1234567890", "template_id": "test-template", "variables": map[string]int{"index": index}, "sandbox": true, // Zero cost load test }) results <- resp }(i) } wg.Wait() close(results) return analyzeResults(results), nil } ``` ### Canary Deployments ```typescript // Validate in sandbox mode before canary async function deployCanary(): Promise { // Validate configuration const sandboxResult = await validateConfig({ sandbox: true }); if (!sandboxResult.valid) return false; // Deploy to 1% of traffic await deployToCanary(0.01); // Monitor, then scale await monitorAndScale(); return true; } ``` ```python # Validate in sandbox mode before canary async def deploy_canary(): # Validate configuration sandbox_result = await validate_config({'sandbox': True}) if not sandbox_result.get('valid'): return False # Deploy to 1% of traffic await deploy_to_canary(0.01) # Monitor, then scale await monitor_and_scale() return True ``` ```go // Validate in sandbox mode before canary func deployCanary() error { // Validate configuration sandboxResult, err := validateConfig(map[string]interface{}{ "sandbox": true, }) if err != nil || !sandboxResult.Valid { return fmt.Errorf("validation failed") } // Deploy to 1% of traffic if err := deployToCanary(0.01); err != nil { return err } // Monitor, then scale return monitorAndScale() } ``` --- ## Test Mode vs Idempotency Both features help you build reliable integrations, but serve different purposes: | Feature | Use Case | Side Effects | Response | |---------|----------|--------------|----------| | **Test Mode** | Development, testing, debugging | None (validation only) | Fake/sample data | | **Idempotency** | Safe retries, duplicate prevention | Only on first request | Real/cached data | ### Using Together ```typescript // The ultimate safety combo await client.request('POST', '/v3/messages', { sandbox: true, // No side effects phone_number: '+1234567890', template_id: 'template-id' }, { idempotencyKey: 'test-001' // Safe to retry }); ``` ```python # The ultimate safety combo client.request( 'POST', '/v3/messages', { 'sandbox': True, # No side effects 'phone_number': '+1234567890', 'template_id': 'template-id' }, idempotency_key='test-001' # Safe to retry ) ``` ```go // The ultimate safety combo client.Request("POST", "/v3/messages", map[string]interface{}{ "sandbox": true, // No side effects "phone_number": "+1234567890", "template_id": "template-id", }, "test-001") // Safe to retry ``` **Pro Tip:** Use test mode during development, idempotency in production. Together, they give you bulletproof reliability. --- ## Troubleshooting ### Sandbox Mode Not Working? **Check:** 1. Is `sandbox` a boolean `true` (not string `"true"`)? 2. Is it in the request body (not headers)? 3. Is the endpoint a mutation (POST/PUT/PATCH/DELETE)? ### Getting 401 Errors? Test mode still requires valid authentication: ```bash # ❌ Won't work — invalid API key curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: invalid-key" \ -d '{"sandbox": true, ...}' # ✅ Works — valid key + sandbox mode curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ -d '{"sandbox": true, ...}' ``` --- ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint.txt TITLE: Get user by ID ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint.txt # GET /v3/users/{userId} Get user by ID Retrieves detailed information about a specific user in an organization or profile. Requires developer role or higher. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersGetUserEndpoint` **Tags:** Users ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `userId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 User retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "880e8400-e29b-41d4-a716-446655440003", "email": "admin@acme.com", "name": "John Admin", "role": "admin", "status": "active", "invited_at": "2025-10-08T15:54:51.3101613+00:00", "last_login_at": "2026-04-08T13:54:51.3102575+00:00", "created_at": "2025-10-08T15:54:51.3103168+00:00", "updated_at": "2026-04-07T15:54:51.3103698+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3211016+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3211146+00:00", "version": "v3" } } ``` ### 404 User not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_006", "message": "User not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3211161+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3211181+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUsersEndpoint.txt TITLE: List users ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersGetUsersEndpoint.txt # GET /v3/users List users Retrieves all users who have access to the organization or profile identified by the API key, including their roles and status. Shows invited users (pending acceptance) and active users. Requires developer role or higher. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersGetUsersEndpoint` **Tags:** Users ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Users retrieved successfully #### application/json ```typescript { success?: boolean, data?: { users?: Array<{ id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "users": [ { "id": "880e8400-e29b-41d4-a716-446655440003", "email": "admin@acme.com", "name": "John Admin", "role": "admin", "status": "active", "invited_at": "2025-10-08T15:54:51.3313712+00:00", "last_login_at": "2026-04-08T13:54:51.3313758+00:00", "created_at": "2025-10-08T15:54:51.331377+00:00", "updated_at": "2026-04-07T15:54:51.3313778+00:00" }, { "id": "990e8400-e29b-41d4-a716-446655440004", "email": "developer@acme.com", "name": "Jane Developer", "role": "developer", "status": "invited", "invited_at": "2026-04-06T15:54:51.33138+00:00", "last_login_at": null, "created_at": "2026-04-06T15:54:51.3313807+00:00", "updated_at": "2026-04-06T15:54:51.3313813+00:00" } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3315488+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.331557+00:00", "version": "v3" } } ``` ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.331559+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3315618+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersInviteUserEndpoint.txt TITLE: Invite a user ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersInviteUserEndpoint.txt # POST /v3/users Invite a user Sends an invitation to a user to join the organization or profile with a specific role. Requires admin role. The user will receive an invitation email with a token to accept. Invitation tokens expire after 7 days. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersInviteUserEndpoint` **Tags:** Users ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 201 User invited successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "aa0e8400-e29b-41d4-a716-446655440005", "email": "newuser@example.com", "name": "New User", "role": "developer", "status": "invited", "invited_at": "2026-04-08T15:54:51.3462488+00:00", "last_login_at": null, "created_at": "2026-04-08T15:54:51.3462497+00:00", "updated_at": "2026-04-08T15:54:51.3462498+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3462508+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "email": [ "Email must be a valid email address" ], "role": [ "Role must be one of: admin, billing, developer" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3463356+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3463365+00:00", "version": "v3" } } ``` ### 404 Organization not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_005", "message": "Organization not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3463373+00:00", "version": "v3" } } ``` ### 409 User already exists in organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_007", "message": "User already exists in this organization", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3463413+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3463421+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersRemoveUserEndpoint.txt TITLE: Remove user ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersRemoveUserEndpoint.txt # DELETE /v3/users/{userId} Remove user Removes a user's access to an organization or profile. Requires admin role. You cannot remove yourself or remove the last admin. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersRemoveUserEndpoint` **Tags:** Users ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `userId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 204 User removed successfully ### 400 Invalid request - Cannot remove yourself or last admin #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "You cannot remove yourself from the organization", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3507496+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.350752+00:00", "version": "v3" } } ``` ### 404 User not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_006", "message": "User not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3507527+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3507535+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersUpdateUserEndpoint.txt TITLE: Update user role ================================================================================ URL: https://docs.sent.dm/llms/reference/api/users/SentDmServicesEndpointsCustomerAPIv3UsersUpdateUserEndpoint.txt # PATCH /v3/users/{userId} Update user role Updates a user's role in the organization or profile. Requires admin role. You cannot change your own role or demote the last admin. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3UsersUpdateUserEndpoint` **Tags:** Users ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `userId` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 User role updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, email?: string, name?: string, role?: string, status?: string, invited_at?: string, last_login_at?: string, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "aa0e8400-e29b-41d4-a716-446655440005", "email": "user@example.com", "name": "User Name", "role": "billing", "status": "active", "invited_at": "2026-03-08T15:54:51.3554685+00:00", "last_login_at": "2026-04-08T10:54:51.3554718+00:00", "created_at": "2026-03-08T15:54:51.3554724+00:00", "updated_at": "2026-04-08T15:54:51.3554726+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3554733+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters or cannot modify own role #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Request validation failed", "details": { "role": [ "Role must be one of: admin, billing, developer" ] }, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.355498+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden - User does not have admin access to this organization #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "AUTH_004", "message": "You do not have admin access to this organization or profile", "details": null, "doc_url": "https://docs.sent.dm/reference/api/authentication" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3554989+00:00", "version": "v3" } } ``` ### 404 User not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_006", "message": "User not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3554997+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred. Please contact support with request ID: req_7X9zKp2jDw", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.3555027+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint.txt TITLE: Create a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint.txt # POST /v3/webhooks Create a webhook Creates a new webhook endpoint for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksCreateWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 201 Webhook created successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": "whsec_a1b2c3d4e5f6g7h8i9j0", "is_active": true, "event_types": [ "messages", "templates" ], "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": null }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.0439004+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Endpoint URL must be a valid HTTP or HTTPS URL", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.0447801+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while creating the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.044782+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksDeleteWebhookEndpoint.txt TITLE: Delete a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksDeleteWebhookEndpoint.txt # DELETE /v3/webhooks/{id} Delete a webhook Deletes a webhook for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksDeleteWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 204 Webhook deleted successfully ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1406696+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1406735+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while deleting the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1406744+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint.txt TITLE: Get a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint.txt # GET /v3/webhooks/{id} Get a webhook Retrieves a single webhook by ID for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Webhook retrieved successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": null, "is_active": true, "event_types": [ "messages", "templates" ], "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": "2026-01-20T14:15:00+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1475784+00:00", "version": "v3" } } ``` ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1475817+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1475822+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1475827+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventTypesEndpoint.txt TITLE: Get available webhook event types ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventTypesEndpoint.txt # GET /v3/webhooks/event-types Get available webhook event types Retrieves all available webhook event types that can be subscribed to. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventTypesEndpoint` **Tags:** Webhooks ## Parameters ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Event types retrieved successfully #### application/json ```typescript { success?: boolean, data?: { event_types?: Array<{ name?: string, display_name?: string, description?: string, is_active?: boolean }> }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "event_types": [ { "name": "message.sent", "display_name": "Message Sent", "description": "Triggered when a message is successfully sent", "is_active": true }, { "name": "message.delivered", "display_name": "Message Delivered", "description": "Triggered when a message is confirmed delivered", "is_active": true }, { "name": "message.failed", "display_name": "Message Failed", "description": "Triggered when a message delivery fails", "is_active": true } ] }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2215399+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving event types", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2215519+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint.txt TITLE: Get webhook events ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint.txt # GET /v3/webhooks/{id}/events Get webhook events Retrieves a paginated list of delivery events for the specified webhook. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhookEventsEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | - | | `page_size` | `integer` | true | - | | `search` | `string` | false | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Webhook events retrieved successfully #### application/json ```typescript { success?: boolean, data?: { events?: Array<{ id?: string, event_type?: string, event_data?: unknown, delivery_status?: string, http_status_code?: integer, response_body?: string, delivery_attempts?: integer, error_message?: string, created_at?: string, processing_started_at?: string, processing_completed_at?: string }>, pagination?: { page?: integer, page_size?: integer, total_count?: integer, total_pages?: integer, has_more?: boolean, cursors?: { after?: string, before?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "events": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "event_type": "message.delivered", "event_data": { "valueKind": "Undefined" }, "delivery_status": "delivered", "http_status_code": 200, "response_body": null, "delivery_attempts": 1, "error_message": null, "created_at": "2026-01-20T14:30:00+00:00", "processing_started_at": "2026-01-20T14:30:01+00:00", "processing_completed_at": "2026-01-20T14:30:02+00:00" } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1540291+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1540335+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.154034+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving webhook events", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.1540345+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint.txt TITLE: Get webhooks list ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint.txt # GET /v3/webhooks Get webhooks list Retrieves a paginated list of webhooks for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksGetWebhooksEndpoint` **Tags:** Webhooks ## Parameters ### Query Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `page` | `integer` | true | - | | `page_size` | `integer` | true | - | | `search` | `string` | false | - | | `is_active` | `boolean` | false | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Responses ### 200 Webhooks retrieved successfully #### application/json ```typescript { success?: boolean, data?: { webhooks?: Array<{ id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }>, pagination?: { page?: integer, page_size?: integer, total_count?: integer, total_pages?: integer, has_more?: boolean, cursors?: { after?: string, before?: string } } }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "webhooks": [ { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": null, "is_active": true, "event_types": [ "messages", "templates" ], "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": null } ], "pagination": { "page": 1, "page_size": 20, "total_count": 1, "total_pages": 1, "has_more": false, "cursors": null } }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2271191+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Page size must be between 1 and 100", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2271238+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while retrieving webhooks", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2271772+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksRotateWebhookSecretEndpoint.txt TITLE: Rotate webhook signing secret ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksRotateWebhookSecretEndpoint.txt # POST /v3/webhooks/{id}/rotate-secret Rotate webhook signing secret Generates a new signing secret for the specified webhook. The old secret is immediately invalidated. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksRotateWebhookSecretEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Secret rotated successfully #### application/json ```typescript { success?: boolean, data?: { signing_secret?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "signing_secret": "whsec_n3wS3cr3tK3yG3n3r4t3d" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2324796+00:00", "version": "v3" } } ``` ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2324869+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2324878+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while rotating the webhook secret", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2324887+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint.txt TITLE: Test a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint.txt # POST /v3/webhooks/{id}/test Test a webhook Sends a test event to the specified webhook endpoint to verify connectivity. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksTestWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Test completed #### application/json ```typescript { success?: boolean, data?: { success?: boolean, message?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "success": true, "message": "Test event delivered successfully" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2372171+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Event type is required", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2372211+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2372219+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while testing the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2372227+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksToggleWebhookStatusEndpoint.txt TITLE: Toggle webhook status ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksToggleWebhookStatusEndpoint.txt # PATCH /v3/webhooks/{id}/toggle-status Toggle webhook status Activates or deactivates a webhook for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksToggleWebhookStatusEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Webhook status updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Order Notifications", "endpoint_url": "https://example.com/webhooks/orders", "signing_secret": null, "is_active": false, "event_types": [ "messages", "templates" ], "retry_count": 3, "timeout_seconds": 30, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": "2026-02-01T09:00:00+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2414285+00:00", "version": "v3" } } ``` ### 400 Invalid webhook ID #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Invalid webhook ID format.", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2414317+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2414325+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while toggling the webhook status", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2414334+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksUpdateWebhookEndpoint.txt TITLE: Update a webhook ================================================================================ URL: https://docs.sent.dm/llms/reference/api/webhooks/SentDmServicesEndpointsCustomerAPIv3WebhooksUpdateWebhookEndpoint.txt # PUT /v3/webhooks/{id} Update a webhook Updates an existing webhook for the authenticated customer. **Operation ID:** `SentDmServicesEndpointsCustomerAPIv3WebhooksUpdateWebhookEndpoint` **Tags:** Webhooks ## Parameters ### Path Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `id` | `string` | true | - | ### Header Parameters | Name | Type | Required | Description | |------|------|----------|-------------| | `Idempotency-Key` | `string` | false | Unique key to ensure idempotent request processing. Must be 1-255 alphanumeric characters, hyphens, or underscores. Responses are cached for 24 hours per key per customer. | | `x-profile-id` | `string` | false | Profile UUID to scope the request to a child profile. Only organization API keys can use this header. The profile must belong to the calling organization. | ## Request Body ### application/json ```typescript object ``` ## Responses ### 200 Webhook updated successfully #### application/json ```typescript { success?: boolean, data?: { id?: string, display_name?: string, endpoint_url?: string, signing_secret?: string, is_active?: boolean, event_types?: Array, retry_count?: integer, timeout_seconds?: integer, last_delivery_attempt_at?: string, last_successful_delivery_at?: string, consecutive_failures?: integer, created_at?: string, updated_at?: string }, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": true, "data": { "id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8", "display_name": "Updated Order Notifications", "endpoint_url": "https://example.com/webhooks/orders-v2", "signing_secret": null, "is_active": true, "event_types": [ "messages", "templates" ], "retry_count": 5, "timeout_seconds": 60, "last_delivery_attempt_at": null, "last_successful_delivery_at": null, "consecutive_failures": 0, "created_at": "2026-01-15T10:30:00+00:00", "updated_at": "2026-02-05T16:45:00+00:00" }, "error": null, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2451902+00:00", "version": "v3" } } ``` ### 400 Invalid request parameters #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "VALIDATION_001", "message": "Endpoint URL must be a valid HTTP or HTTPS URL", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2451929+00:00", "version": "v3" } } ``` ### 401 Unauthorized - Invalid API credentials ### 403 Forbidden ### 404 Webhook not found #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "RESOURCE_008", "message": "Webhook not found", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2451936+00:00", "version": "v3" } } ``` ### 500 Internal server error #### application/json ```typescript { success?: boolean, error?: { code?: string, message?: string, details?: object, doc_url?: string }, meta?: unknown } ``` ##### Example ```json { "success": false, "data": null, "error": { "code": "INTERNAL_001", "message": "An unexpected error occurred while updating the webhook", "details": null, "doc_url": "https://docs.sent.dm/reference/api/error-catalog" }, "meta": { "request_id": "req_7X9zKp2jDw", "timestamp": "2026-04-08T15:54:51.2451944+00:00", "version": "v3" } } ``` ## Security - **CustomerApiKey** ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/changelog.txt TITLE: Changelog ================================================================================ URL: https://docs.sent.dm/llms/reference/changelog.txt A chronological record of platform updates, new features, and important changes to Sent's APIs and services # Changelog A chronological record of platform updates, new features, and important changes to Sent's APIs and services. --- ## API v3 ### Overview Sent API v3 is the current major version, featuring a redesigned authentication system, consistent response formats, and expanded functionality for multi-channel messaging. ### Key Changes from v2 | Feature | v2 | v3 | |---------|-----|-----| | Authentication | `x-sender-id` + `x-api-key` headers | `x-api-key` header only | | Response Format | FastEndpoints problem details | Consistent JSON envelope | | Property Naming | Mixed case | snake_case | | Sandbox Mode | Not available | `sandbox` field in body | | Idempotency | Not available | `Idempotency-Key` header | | Error Codes | Limited | Comprehensive error catalog | | Profiles | Limited | Full CRUD operations | | Brands/Campaigns | Not available | Full 10DLC support | | Users | Not available | Organization management | | Webhooks | v2 endpoints | Enhanced v3 webhooks | --- ## 2024 ### December 2024 #### API v3 General Availability - **Added**: Sent API v3 is now the recommended version for all integrations - **Added**: Comprehensive API documentation for v3 endpoints - **Added**: New getting started guide for v3 ### November 2024 #### Webhook Enhancements - **Added**: `GET /v3/webhooks/event-types` endpoint to list available event types - **Added**: `POST /v3/webhooks/{id}/test` endpoint to test webhook delivery - **Added**: `POST /v3/webhooks/{id}/rotate-secret` endpoint for security rotation - **Added**: `PATCH /v3/webhooks/{id}/toggle-status` endpoint to enable/disable webhooks #### User Management - **Added**: `GET /v3/users` and `GET /v3/users/{id}` for user listing and details - **Added**: `POST /v3/users` to invite users to your organization - **Added**: `PATCH /v3/users/{id}` to update user roles - **Added**: `DELETE /v3/users/{id}` to remove users ### October 2024 #### 10DLC Compliance (Brands & Campaigns) - **Added**: Brand registration endpoints for 10DLC compliance - `POST /v3/brands` - Create brand - `GET /v3/brands` - List brands - `PUT /v3/brands/{id}` - Update brand - `DELETE /v3/brands/{id}` - Delete brand - **Added**: Campaign management endpoints - `POST /v3/brands/{id}/campaigns` - Create campaign - `GET /v3/brands/{id}/campaigns` - List campaigns - `PUT /v3/brands/{id}/campaigns/{id}` - Update campaign - `DELETE /v3/brands/{id}/campaigns/{id}` - Delete campaign #### Profile Management - **Added**: Full profile CRUD operations - `POST /v3/profiles` - Create profile - `GET /v3/profiles` - List profiles - `GET /v3/profiles/{id}` - Get profile details - `PATCH /v3/profiles/{id}` - Update profile - `DELETE /v3/profiles/{id}` - Delete profile - **Added**: `POST /v3/profiles/{id}/complete` for profile setup completion ### September 2024 #### API v3 Beta Release - **Added**: Core messaging endpoints - `POST /v3/messages` - Send messages - `GET /v3/messages/{id}` - Get message status - `GET /v3/messages/{id}/activities` - Get message activities - **Added**: Contact management - `GET /v3/contacts` - List contacts - `POST /v3/contacts` - Create contact - `GET /v3/contacts/{id}` - Get contact - `PATCH /v3/contacts/{id}` - Update contact - `DELETE /v3/contacts/{id}` - Delete contact - **Added**: Template management - `GET /v3/templates` - List templates - `POST /v3/templates` - Create template - `GET /v3/templates/{id}` - Get template - `PUT /v3/templates/{id}` - Update template - `DELETE /v3/templates/{id}` - Delete template - **Added**: Webhook management - `GET /v3/webhooks` - List webhooks - `POST /v3/webhooks` - Create webhook - `GET /v3/webhooks/{id}` - Get webhook - `PUT /v3/webhooks/{id}` - Update webhook - `DELETE /v3/webhooks/{id}` - Delete webhook - `GET /v3/webhooks/{id}/events` - List webhook events - **Added**: Number lookup - `GET /v3/lookup/number/{phone}` - Lookup phone number information - **Added**: Account information - `GET /v3/me` - Get authenticated account details #### New Features - **Added**: Sandbox mode (`sandbox: true`) for all mutation endpoints - **Added**: Idempotency key support via `Idempotency-Key` header - **Added**: Consistent JSON response envelope (`success`, `data`, `error`, `meta`) - **Added**: Standardized error codes with documentation URLs - **Added**: snake_case property naming convention - **Added**: Enhanced rate limiting with detailed headers --- ## 2023 ### Legacy API v2 The v2 API remains fully supported for existing integrations. All v2 endpoints continue to operate at `/v2/` paths. Key v2 endpoints: - `POST /v2/messages/contact` - Send message to contact - `POST /v2/messages/phone` - Send message to phone number - `GET /v2/contacts` - List contacts - `GET /v2/templates` - List templates See [Legacy API Reference](/reference-legacy/api) for complete v2 documentation. --- ## Deprecation Notices ### API v2 - **Status**: Legacy (still supported) - **Recommendation**: New integrations should use v3 - **End of Support**: No end date announced --- ## Upcoming Changes ### Planned for 2025 - **Analytics API**: Message delivery analytics and reporting endpoints - **Bulk Operations**: Endpoints for batch contact and message operations - **Advanced Templates**: Template versioning and A/B testing support - **SDKs**: Official client libraries for JavaScript, Python, and Go --- Subscribe to our [API Status](https://status.sent.dm) page for real-time updates on API changes and service status. --- ## Feedback Have suggestions for API improvements? Contact us at [support@sent.dm](mailto:support@sent.dm). ================================================================================ SOURCE: https://docs.sent.dm/llms/reference/glossary.txt TITLE: Glossary ================================================================================ URL: https://docs.sent.dm/llms/reference/glossary.txt Definitions of key terms and concepts used throughout the Sent platform and documentation # Glossary Definitions of key terms and concepts used throughout the Sent platform and documentation. ## A ### API Key Authentication token used to access Sent's API endpoints. - **API Key**: Provides access to all customer data through the `x-api-key` header ### Authentication Message WhatsApp message category for OTP codes, verification messages, and login confirmations. Usually has the lowest cost per message. ### Available Channels Comma-separated list of messaging channels (e.g., "sms,whatsapp") that can reach a specific contact based on their phone number validation. ## B ### Balance Customer account credit amount used for message payments. Tracked via `CustomerBalance` with detailed transaction history. ### Business Account WhatsApp Business Account (WABA) identifier required for WhatsApp messaging integration. ## C ### Channel Communication method used to deliver messages: - **SMS**: Traditional text messaging - **WhatsApp**: Business messaging via Meta's WhatsApp Business API ### Channel Provider Service integration handling specific messaging channels (e.g., `WhatsappChannelProvider`, `SmsProvider`). ### Contact A phone number recipient in your messaging system, including validation data, available channels, and formatting information. ### Contact ID Unique UUID identifier for a contact within a customer's account. ### Customer Primary entity representing a business account in the Sent platform. Contains messaging configuration, API keys, and channel settings. ### Customer ID Unique UUID identifier for a customer account. ## D ### Default Channel Preferred messaging channel for a contact, determined by phone number validation and regional settings. ### Delivery Status Message state indicating current status: `PENDING`, `SENT`, `DELIVERED`, or `FAILED`. ## E ### E.164 Format International phone number standard (e.g., `+1234567890`) used as the canonical format throughout the platform. ### Endpoint API route handling specific operations (e.g., `/v2/contacts/id`, `/v2/messages/phone`). ## F ### FastEndpoints .NET framework used for building API endpoints with built-in validation and documentation. ### Formatting Phone number representations in various formats: - **E.164**: `+1234567890` (canonical) - **International**: `+1 234-567-890` (human-readable) - **National**: `(234) 567-890` (country-specific) - **RFC 3966**: `tel:+1-234-567-890` (URI format) ## I ### InstantiatedPhones Phone numbers available in the system for customer assignment, managed for SMS messaging. ## K ### KYC (Know Your Customer) Compliance data including business information, contact details, and use case descriptions required for messaging services. ## M ### Marketing Message WhatsApp message category for promotional content and campaigns. Typically has higher cost per message and requires opt-in. ### Message Activity Event tracking for message processing including channel selection, sending status, and delivery confirmation. ### Message Body Final rendered content of a message after template variable substitution. ### Message Template Pre-approved message format for WhatsApp or reusable content for SMS, supporting variable substitution. ### Messaging Profile SMS provider configuration for messaging, containing webhook URLs and delivery settings. ## P ### Pagination Result set structure with `items`, `totalCount`, `page`, `pageSize`, and `totalPages` for handling large data sets. ### Phone Number Validation Process determining if a phone number is valid, possible, and identifying its type (mobile, fixed-line, etc.). ### Pricing Cost structure for messages varying by channel, region, and message category (especially for WhatsApp). ## R ### Region Code ISO 3166-1 alpha-2 country code (e.g., "US", "CA") used for pricing and channel availability. ### Repository Pattern Data access layer using Dapper for database operations with async/await patterns. ## S ### Sender ID Customer identifier used in conjunction with API keys for request authentication and data scoping. ### SMS Short Message Service - traditional text messaging. ### System User Access Token Long-lived WhatsApp API authentication token for business messaging operations. ## T ### Template Pre-defined message format supporting variable substitution: - **WhatsApp Template**: Must be approved by Meta before use - **SMS Template**: Can be used immediately without approval ### Template Category Classification for WhatsApp templates: - **AUTHENTICATION**: OTP and verification messages - **MARKETING**: Promotional content requiring opt-in - **UTILITY**: Transactional messages and confirmations ### Template Status WhatsApp template approval state: `PENDING`, `APPROVED`, or `REJECTED`. ### Transaction Financial record tracking balance changes from message costs, payments, or refunds. ## U ### Unified Messaging Intelligence Sent's system for automatically selecting the optimal messaging channel based on contact validation, availability, and cost. ### UUID (Universally Unique Identifier) 128-bit identifier format used for all entity IDs throughout the platform. ### Utility Message WhatsApp message category for transactional content like order confirmations and delivery notifications. ## V ### Validation Process of verifying phone number format, reachability, and available messaging channels. ### Variable Substitution Process of replacing placeholder values (e.g., `{{1}}`, `{{name}}`) in message templates with actual content. ## W ### Webhook HTTP callback URL for receiving real-time message status updates and delivery confirmations. ### WhatsApp Business API Meta's business messaging platform integrated for professional messaging capabilities. ### WhatsApp Template Pre-approved message format required by Meta for business messaging, supporting rich media and interactive elements. --- ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/best-practices.txt TITLE: SDK Best Practices ================================================================================ URL: https://docs.sent.dm/llms/sdks/best-practices.txt Production-ready patterns for using Sent SDKs. Error handling, retries, testing, and webhook security. # SDK Best Practices Build production-ready messaging integrations with Sent LogoSent SDKs. This guide covers patterns for error handling, retries, testing, and security that apply across all languages. These practices are recommended for production deployments. For quick prototyping, the basic SDK usage shown in language-specific guides is sufficient. ## Error Handling Strategy ### Handle Errors by Exception Type (TypeScript, Python, Java, C#) Most SDKs throw exceptions for errors. Catch specific exception types: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log(`Sent: ${response.data.messages[0].id}`); } catch (error) { if (error instanceof SentDm.BadRequestError) { console.error('Invalid request:', error.message); } else if (error instanceof SentDm.RateLimitError) { console.error('Rate limited. Retry after:', error.headers['retry-after']); } else if (error instanceof SentDm.AuthenticationError) { console.error('Invalid API key'); } else if (error instanceof SentDm.APIError) { console.error(`API Error ${error.status}:`, error.message); } else { console.error('Unexpected error:', error); } } ``` ```python import sent_dm from sent_dm import SentDm client = SentDm() try: response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(f"Sent: {response.data.messages[0].id}") except sent_dm.BadRequestError as e: print(f"Invalid request: {str(e)}") except sent_dm.RateLimitError as e: print(f"Rate limited. Retry after: {e.response.headers.get('retry-after')}") except sent_dm.AuthenticationError as e: print("Invalid API key") except sent_dm.APIStatusError as e: print(f"API Error {e.status_code}: {str(e)}") except sent_dm.APIError as e: print(f"Unexpected error: {e}") ``` ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import dm.sent.exceptions.BadRequestException; import dm.sent.exceptions.RateLimitException; import dm.sent.exceptions.AuthenticationException; SentDmClient client = SentDmOkHttpClient.fromEnv(); try { MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("welcome") .build()) .build(); var response = client.messages().send(params); System.out.println("Sent: " + response.data().messages().get(0).id()); } catch (BadRequestException e) { System.err.println("Invalid request: " + e.getMessage()); } catch (RateLimitException e) { System.err.println("Rate limited. Retry after: " + e.retryAfter()); } catch (AuthenticationException e) { System.err.println("Invalid API key"); } catch (SentDmException e) { System.err.println("API Error: " + e.getMessage()); } ``` ```csharp using Sentdm; SentDmClient client = new(); try { MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome" } }; var response = await client.Messages.SendAsync(parameters); Console.WriteLine($"Sent: {response.Data.Messages[0].Id}"); } catch (SentDmBadRequestException e) { Console.WriteLine($"Invalid request: {e.Message}"); } catch (SentDmRateLimitException e) { Console.WriteLine("Rate limited"); } catch (SentDmUnauthorizedException e) { Console.WriteLine("Invalid API key"); } catch (SentDmApiException e) { Console.WriteLine($"API Error: {e.Message}"); } ``` ```go import ( "errors" "github.com/sentdm/sent-dm-go" ) client := sentdm.NewClient() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, }) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 400: fmt.Println("Invalid request:", apiErr.Message) case 429: fmt.Println("Rate limited") case 401: fmt.Println("Invalid API key") default: fmt.Printf("API Error %d: %s\n", apiErr.StatusCode, apiErr.Message) } } else { fmt.Println("Network error:", err) } } else { fmt.Printf("Sent: %s\n", response.Data.Messages[0].ID) } ``` ### Handle Specific Error Codes Different errors require different handling strategies: ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log(`Sent: ${response.data.messages[0].id}`); } catch (error) { if (error instanceof SentDm.RateLimitError) { // Back off and retry const retryAfter = parseInt(error.headers['retry-after'] || '60', 10); await delay(retryAfter * 1000); return retry(); } if (error instanceof SentDm.BadRequestError) { // Check specific error code if available in message if (error.message.includes('TEMPLATE_001')) { // Template not found - check template ID console.error('Template not found'); } else if (error.message.includes('INSUFFICIENT_CREDITS')) { // Alert operations team await alertOpsTeam('Account balance low'); } } throw error; } ``` ```python import time import sent_dm try: response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(f"Sent: {response.data.messages[0].id}") except sent_dm.RateLimitError as e: # Back off and retry retry_after = int(e.response.headers.get('retry-after', 60)) time.sleep(retry_after) return retry() except sent_dm.BadRequestError as e: # Check specific error in message if 'TEMPLATE_001' in str(e): print("Template not found") elif 'INSUFFICIENT_CREDITS' in str(e): await alert_ops_team('Account balance low') raise ``` ## Retry Strategies ### Use Built-in Retries All SDKs have built-in retry logic with exponential backoff: ```typescript // Configure max retries (default is 2) const client = new SentDm({ maxRetries: 3 // Retry up to 3 times }); // Or per-request await client.messages.send(params, { maxRetries: 5 }); ``` ```python from sent_dm import SentDm # Configure max retries (default is 2) client = SentDm(max_retries=3) # Or per-request client.with_options(max_retries=5).messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) ``` ```go // Configure max retries (default is 2) client := sentdm.NewClient( option.WithMaxRetries(3), ) ``` ```java SentDmClient client = SentDmOkHttpClient.builder() .maxRetries(3) .build(); ``` ### Custom Retry Logic For application-specific retry logic: ```typescript async function sendWithRetry( client: SentDm, params: SentDm.MessageSendParams, maxRetries = 3 ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await client.messages.send(params); } catch (error) { // Don't retry client errors (4xx) except rate limit if (error instanceof SentDm.BadRequestError && !(error instanceof SentDm.RateLimitError)) { throw error; } // Don't retry on last attempt if (attempt === maxRetries) { throw error; } // Exponential backoff: 1s, 2s, 4s const delayMs = Math.min(1000 * Math.pow(2, attempt), 10000); await sleep(delayMs); } } throw new Error('Unreachable'); } ``` ```python import time from sent_dm import SentDm def send_with_retry( client: SentDm, to: list[str], template: dict, max_retries: int = 3 ): for attempt in range(max_retries + 1): try: return client.messages.send( to=to, template=template ) except Exception as e: # Don't retry client errors (4xx) except rate limit if hasattr(e, 'status_code') and e.status_code == 429: pass # Will retry elif hasattr(e, 'status_code') and 400 <= e.status_code < 500: raise # Don't retry on last attempt if attempt == max_retries: raise # Exponential backoff: 1s, 2s, 4s delay_ms = min(1000 * (2 ** attempt), 10000) time.sleep(delay_ms / 1000) ``` ## Testing Strategies ### Use Test Mode Always use `test_mode` in development and CI/CD: All SDKs support `test_mode` parameter to validate requests without sending real messages. Use this in development and CI/CD environments. ```typescript // Enable test mode to validate without sending const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, testMode: true // Validates but doesn't send }); ``` ```python # Enable test mode to validate without sending response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" }, test_mode=True # Validates but doesn't send ) ``` ```go // Enable test mode to validate without sending response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, TestMode: sentdm.Bool(true), // Validates but doesn't send }) ``` ```java // Enable test mode to validate without sending MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("welcome") .build()) .testMode(true) // Validates but doesn't send .build(); var response = client.messages().send(params); ``` ```csharp // Enable test mode to validate without sending MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome" }, TestMode = true // Validates but doesn't send }; var response = await client.Messages.SendAsync(parameters); ``` ```php // Enable test mode to validate without sending $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], testMode: true // Validates but doesn't send ); ``` ### Mock the SDK in Unit Tests Don't make real API calls in unit tests: ```typescript // jest.mock example jest.mock('@sentdm/sentdm', () => ({ default: jest.fn().mockImplementation(() => ({ messages: { send: jest.fn().mockResolvedValue({ data: { messages: [{ id: 'msg_123', status: 'pending' }] } }) } })) })); // Test your business logic it('should send welcome message on signup', async () => { await userService.signup({ phone: '+1234567890' }); // Verify the SDK method was called expect(mockSend).toHaveBeenCalledWith({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); }); ``` ```python # pytest example with unittest.mock from unittest.mock import Mock, patch def test_send_welcome_message(): mock_client = Mock() mock_client.messages.send.return_value = Mock( data=Mock( messages=[Mock(id='msg_123', status='pending')] ) ) with patch('myapp.services.SentDm', return_value=mock_client): user_service.signup(phone='+1234567890') mock_client.messages.send.assert_called_with( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) ``` ## Language-Specific API Patterns Each SDK follows the idiomatic patterns of its language. Here are the key differences: ### Method Naming Conventions | Language | Method Style | Example | |----------|--------------|---------| | **TypeScript** | camelCase | `client.webhooks.verifySignature()` | | **Python** | snake_case | `client.webhooks.verify_signature()` | | **Go** | PascalCase (exported) | `client.Webhooks.VerifySignature()` | | **Java** | camelCase | `client.webhooks().verifySignature()` | | **C#** | PascalCase | `client.Webhooks.VerifySignature()` | | **PHP** | camelCase | `$client->webhooks->verifySignature()` | | **Ruby** | snake_case | `client.webhooks.verify_signature` | ### Error Handling Patterns **Exception-based (TypeScript, Python, Java, C#, PHP, Ruby):** - SDKs throw exceptions for API errors - Catch specific exception types for different handling - Network errors throw connection exceptions **Error return (Go):** - Go returns errors as second value - Check `err != nil` before using response - API errors are typed errors ## Webhook Security ### Always Verify Signatures Never process webhooks without verifying the signature. Unsigned webhooks could be spoofed. ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-webhook-signature'] as string; const payload = req.body.toString(); // Verify using SDK const isValid = client.webhooks.verifySignature({ payload, signature, secret: process.env.SENT_DM_WEBHOOK_SECRET! }); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } // Process webhook const event = JSON.parse(payload); processWebhook(event); res.json({ received: true }); }); ``` ```python @app.route('/webhooks/sent', methods=['POST']) def webhook(): signature = request.headers.get('X-Webhook-Signature') payload = request.get_data(as_text=True) # Verify using SDK is_valid = client.webhooks.verify_signature( payload=payload, signature=signature, secret=os.environ['SENT_DM_WEBHOOK_SECRET'] ) if not is_valid: return jsonify({'error': 'Invalid signature'}), 401 # Process webhook event = request.get_json() process_webhook(event) return jsonify({'received': True}) ``` ```go func webhookHandler(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get("X-Webhook-Signature") body, _ := io.ReadAll(r.Body) payload := string(body) // Verify using SDK isValid := client.Webhooks.VerifySignature(payload, signature, webhookSecret) if !isValid { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // Process webhook processWebhook(payload) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"received": true}) } ``` ### Handle Webhooks Idempotently Webhook events may be delivered multiple times. Handle them idempotently: ```typescript async function processWebhook(event: WebhookEvent) { const eventId = event.meta?.request_id || event.id; // Check if already processed const existing = await db.webhookEvents.findUnique({ where: { eventId } }); if (existing) { console.log(`Event ${eventId} already processed`); return { received: true }; } // Process event await handleEvent(event); // Record as processed await db.webhookEvents.create({ data: { eventId, type: event.type, processedAt: new Date() } }); return { received: true }; } ``` ### Respond Quickly Webhook handlers should respond quickly to avoid timeouts: ```typescript app.post('/webhooks/sent', async (req, res) => { // Verify signature if (!isValidSignature(req)) { return res.status(401).end(); } // Respond immediately res.status(200).json({ received: true }); // Process asynchronously processWebhook(req.body).catch(err => { logger.error('Webhook processing failed', err); }); }); ``` ## Performance Optimization ### Reuse Client Instances Create one client instance and reuse it across your application. Don't create a new client for each request. ```typescript // config/sent.ts import SentDm from '@sentdm/sentdm'; // Create once export const sentClient = new SentDm(); // Use everywhere import { sentClient } from './config/sent'; export async function sendMessage(params) { return sentClient.messages.send(params); } ``` ```python # config/sent.py from sent_dm import SentDm # Create once sent_client = SentDm() # Use everywhere from config.sent import sent_client def send_message(phone_number, template_id): return sent_client.messages.send( to=[phone_number], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": template_id } ) ``` ```go // config/sent.go var Client *sentdm.Client func init() { Client = sentdm.NewClient() } // Use everywhere import "myapp/config" func sendMessage(params sentdm.MessageSendParams) error { _, err := config.Client.Messages.Send(ctx, params) return err } ``` ```java // config/SentConfig.java @Configuration public class SentConfig { @Bean public SentDmClient sentClient() { return SentDmOkHttpClient.fromEnv(); } } // Use via injection @Service public class MessageService { private final SentDmClient client; public MessageService(SentDmClient client) { this.client = client; } } ``` ### Store Message IDs Always store message IDs for tracking: ```typescript async function sendOrderConfirmation(order: Order) { try { const response = await client.messages.send({ to: [order.customer.phone], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'order-confirmation', parameters: { order_id: order.id } } }); // Store for webhook correlation await db.messages.create({ sentMessageId: response.data.messages[0].id, orderId: order.id, status: response.data.messages[0].status, sentAt: new Date() }); return response; } catch (error) { // Handle error await db.messages.create({ orderId: order.id, status: 'failed', error: error.message, sentAt: new Date() }); throw error; } } ``` ```python def send_order_confirmation(order): try: response = client.messages.send( to=[order.customer.phone], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'order-confirmation', 'parameters': {'order_id': order.id} } ) # Store for webhook correlation db.messages.create( sent_message_id=response.data.messages[0].id, order_id=order.id, status=response.data.messages[0].status, sent_at=datetime.now() ) return response except Exception as e: # Handle error db.messages.create( order_id=order.id, status='failed', error=str(e), sent_at=datetime.now() ) raise ``` ## Environment Management ### Separate Credentials by Environment ```typescript // config/sent.ts const configs = { development: { apiKey: process.env.SENT_DM_API_KEY_TEST }, staging: { apiKey: process.env.SENT_DM_API_KEY_TEST }, production: { apiKey: process.env.SENT_DM_API_KEY } }; const env = (process.env.NODE_ENV as keyof typeof configs) || 'development'; export const sentClient = new SentDm(configs[env]); ``` ### Validate Configuration on Startup ```typescript function validateSentConfig() { if (!process.env.SENT_DM_API_KEY) { throw new Error('SENT_DM_API_KEY is required'); } if (!process.env.SENT_DM_WEBHOOK_SECRET) { console.warn('SENT_DM_WEBHOOK_SECRET not set - webhooks will fail'); } } // Run on application startup validateSentConfig(); ``` ## Summary --- Following these practices ensures your Sent integration is reliable, secure, and maintainable at scale. ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/csharp.txt TITLE: C# / .NET SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/csharp.txt Official .NET SDK for Sent. Native async/await support for ASP.NET Core and .NET applications. # C# / .NET SDK The official .NET SDK for Sent LogoSent provides first-class C# support with native async/await, dependency injection integration, and full compatibility with .NET Standard 2.0+. ## Requirements This library requires .NET Standard 2.0 or later. ## Installation ```bash dotnet add package Sentdm ``` ```powershell Install-Package Sentdm ``` ```xml ``` ## Quick Start ### Initialize the client ```csharp using Sentdm; // Configured using the SENT_DM_API_KEY environment variable SentDmClient client = new(); ``` ### Send your first message ```csharp using Sentdm; using Sentdm.Models.Messages; using System.Collections.Generic; SentDmClient client = new(); MessageSendParams parameters = new() { To = new List { "+1234567890" }, Channel = new List { "sms", "whatsapp" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome", Parameters = new Dictionary() { { "name", "John Doe" }, { "order_id", "12345" } } } }; var response = await client.Messages.SendAsync(parameters); Console.WriteLine($"Sent: {response.Data.Messages[0].Id}"); Console.WriteLine($"Status: {response.Data.Messages[0].Status}"); ``` ## Client configuration Configure the client using environment variables or explicitly: | Property | Environment variable | Required | Default value | |----------|---------------------|----------|---------------| | `ApiKey` | `SENT_DM_API_KEY` | true | - | | `BaseUrl` | `SENT_DM_BASE_URL` | true | `"https://api.sent.dm"` | ```csharp using Sentdm; // Using environment variables SentDmClient client = new(); // Or explicit configuration SentDmClient client = new() { ApiKey = "your_api_key", }; // Or a combination SentDmClient client = new() { ApiKey = "your_api_key", // Explicit // Other settings from environment }; ``` ### Modifying configuration To temporarily use a modified client configuration, while reusing the same connection and thread pools, call `WithOptions`: ```csharp var clientWithOptions = client.WithOptions(options => options with { BaseUrl = "https://example.com", MaxRetries = 5, } ); ``` Using a [`with` expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/with-expression) makes it easy to construct the modified options. ## Send Messages ### Send a message ```csharp using Sentdm.Models.Messages; MessageSendParams parameters = new() { To = new List { "+1234567890" }, Channel = new List { "sms", "whatsapp" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome", Parameters = new Dictionary() { { "name", "John Doe" }, { "order_id", "12345" } } } }; var response = await client.Messages.SendAsync(parameters); Console.WriteLine($"Message ID: {response.Data.Messages[0].Id}"); Console.WriteLine($"Status: {response.Data.Messages[0].Status}"); ``` ### Test mode Use `TestMode = true` to validate requests without sending real messages: ```csharp MessageSendParams parameters = new() { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { Id = "7ba7b820-9dad-11d1-80b4-00c04fd430c8", Name = "welcome" }, TestMode = true // Validates but doesn't send }; var response = await client.Messages.SendAsync(parameters); // Response will have test data Console.WriteLine($"Validation passed: {response.Data.Messages[0].Id}"); ``` ## Error handling The SDK throws custom unchecked exception types: | Status | Exception | |--------|-----------| | 400 | `SentDmBadRequestException` | | 401 | `SentDmUnauthorizedException` | | 403 | `SentDmForbiddenException` | | 404 | `SentDmNotFoundException` | | 422 | `SentDmUnprocessableEntityException` | | 429 | `SentDmRateLimitException` | | 5xx | `SentDm5xxException` | ```csharp try { var response = await client.Messages.SendAsync(parameters); Console.WriteLine($"Sent: {response.Data.Messages[0].Id}"); } catch (SentDmNotFoundException e) { Console.WriteLine($"Not found: {e.Message}"); } catch (SentDmRateLimitException e) { Console.WriteLine($"Rate limited. Retry after delay"); } catch (SentDmApiException e) { Console.WriteLine($"API Error: {e.Message}"); } ``` ## Raw responses To access response headers, status code, or raw body, prefix any HTTP method call with `WithRawResponse`: ```csharp var response = await client.WithRawResponse.Messages.SendAsync(parameters); var statusCode = response.StatusCode; var headers = response.Headers; // Deserialize if needed var deserialized = await response.Deserialize(); ``` ## Retries The SDK automatically retries 2 times by default, with a short exponential backoff between requests. Only the following error types are retried: - Connection errors - 408 Request Timeout - 409 Conflict - 429 Rate Limit - 5xx Internal ```csharp using Sentdm; // Configure for all requests SentDmClient client = new() { MaxRetries = 3 }; // Or per-request await client .WithOptions(options => options with { MaxRetries = 3 }) .Messages.SendAsync(parameters); ``` ## Timeouts Requests time out after 1 minute by default. ```csharp using System; using Sentdm; // Configure for all requests SentDmClient client = new() { Timeout = TimeSpan.FromSeconds(30) }; // Or per-request await client .WithOptions(options => options with { Timeout = TimeSpan.FromSeconds(30) }) .Messages.SendAsync(parameters); ``` ## Contacts Create and manage contacts: ```csharp using Sentdm.Models.Contacts; // Create a contact ContactCreateParams createParams = new() { PhoneNumber = "+1234567890" }; var contact = await client.Contacts.CreateAsync(createParams); Console.WriteLine($"Contact ID: {contact.Data.Id}"); // List contacts ContactListParams listParams = new() { Limit = 100 }; var contacts = await client.Contacts.ListAsync(listParams); foreach (var c in contacts.Data.Data) { Console.WriteLine($"{c.PhoneNumber} - {c.AvailableChannels}"); } // Get a contact var contact = await client.Contacts.GetAsync("contact-uuid"); // Update a contact ContactUpdateParams updateParams = new() { PhoneNumber = "+1987654321" }; var updated = await client.Contacts.UpdateAsync("contact-uuid", updateParams); // Delete a contact await client.Contacts.DeleteAsync("contact-uuid"); ``` ## Templates List and retrieve templates: ```csharp using Sentdm.Models.Templates; // List templates var templates = await client.Templates.ListAsync(); foreach (var template in templates.Data.Data) { Console.WriteLine($"{template.Name} ({template.Status}): {template.Id}"); } // Get a specific template var template = await client.Templates.GetAsync("template-uuid"); Console.WriteLine($"Name: {template.Data.Name}"); Console.WriteLine($"Status: {template.Data.Status}"); ``` ## ASP.NET Core Integration ### Dependency Injection Setup ```csharp // Program.cs using Sentdm; var builder = WebApplication.CreateBuilder(args); // Add Sent client builder.Services.AddSentDm(options => { options.ApiKey = builder.Configuration["Sent:ApiKey"]!; }); var app = builder.Build(); ``` ### Minimal API Example ```csharp // Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddSentDm(options => { options.ApiKey = builder.Configuration["Sent:ApiKey"]!; }); var app = builder.Build(); // Send message endpoint app.MapPost("/api/send-message", async ( MessageSendParams req, SentDmClient client, CancellationToken ct) => { try { var result = await client.Messages.SendAsync(req, ct); return Results.Ok(new { MessageId = result.Data.Messages[0].Id, Status = result.Data.Messages[0].Status, }); } catch (SentDmApiException ex) { return Results.BadRequest(new { Error = ex.Message }); } }); app.Run(); ``` ### MVC Controller ```csharp // Controllers/MessagesController.cs using Microsoft.AspNetCore.Mvc; using Sentdm; using Sentdm.Models.Messages; namespace MyApp.Controllers; [ApiController] [Route("api/[controller]")] public class MessagesController : ControllerBase { private readonly SentDmClient _client; private readonly ILogger _logger; public MessagesController(SentDmClient client, ILogger logger) { _client = client; _logger = logger; } [HttpPost("send")] public async Task SendMessage([FromBody] MessageSendParams request) { try { var result = await _client.Messages.SendAsync(request); return Ok(new { MessageId = result.Data.Messages[0].Id, Status = result.Data.Messages[0].Status }); } catch (SentDmApiException ex) { _logger.LogError(ex, "Failed to send message"); return BadRequest(new { Error = ex.Message }); } } } ``` ## Client configuration from appsettings.json ```json { "Sent": { "ApiKey": "your_api_key_here", "BaseUrl": "https://api.sent.dm", "Timeout": 30, "MaxRetries": 3 } } ``` ```csharp // Program.cs builder.Services.AddSentDm(options => { var config = builder.Configuration.GetSection("Sent"); options.ApiKey = config["ApiKey"]!; options.BaseUrl = config["BaseUrl"]; options.Timeout = TimeSpan.FromSeconds(config.GetValue("Timeout")); options.MaxRetries = config.GetValue("MaxRetries"); }); ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```csharp using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("webhooks")] public class WebhookController : ControllerBase { [HttpPost("sent")] public async Task HandleWebhook() { // 1. Read raw body — do NOT use [FromBody] here using var ms = new MemoryStream(); await Request.Body.CopyToAsync(ms); var payload = ms.ToArray(); var webhookId = Request.Headers["X-Webhook-ID"].ToString(); var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString(); var signature = Request.Headers["X-Webhook-Signature"].ToString(); // 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" var secret = Environment.GetEnvironmentVariable("SENT_WEBHOOK_SECRET")!; // "whsec_..." var keyBase64 = secret.StartsWith("whsec_") ? secret[6..] : secret; var keyBytes = Convert.FromBase64String(keyBase64); var signed = Encoding.UTF8.GetBytes($"{webhookId}.{timestamp}.{Encoding.UTF8.GetString(payload)}"); var expected = $"v1,{Convert.ToBase64String(HMACSHA256.HashData(keyBytes, signed))}"; if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expected))) { return Unauthorized(new { error = "Invalid signature" }); } // 3. Optional: reject replayed events older than 5 minutes if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300) return Unauthorized(new { error = "Timestamp too old" }); // 4. Handle events — update message status in your own database var doc = JsonDocument.Parse(payload); var field = doc.RootElement.GetProperty("field").GetString(); var data = doc.RootElement.GetProperty("payload"); if (field == "messages") { var messageId = data.GetProperty("message_id").GetString(); var status = data.GetProperty("message_status").GetString(); // await db.Messages.Where(m => m.SentId == Guid.Parse(messageId!)).ExecuteUpdateAsync(...) } // 5. Always return 200 quickly return Ok(new { received = true }); } } ``` See the [Webhooks reference](/reference/api/webhooks) for the full payload schema and all status values. ## Source & Issues - **Version**: 0.17.0 - **GitHub**: [sentdm/sent-dm-csharp](https://github.com/sentdm/sent-dm-csharp) - **NuGet**: [Sentdm](https://www.nuget.org/packages/Sentdm) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-csharp/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/csharp/integrations/aspnet-core.txt TITLE: ASP.NET Core Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/csharp/integrations/aspnet-core.txt Dependency injection, Minimal APIs, and MVC with ASP.NET Core # ASP.NET Core Integration Complete ASP.NET Core integration with dependency injection, configuration management, and production-ready patterns. This example uses .NET 8 best practices including the Options pattern, Minimal APIs, ProblemDetails, and built-in rate limiting. ## Project Structure ``` ├── src/ │ ├── Controllers/MessagesController.cs │ ├── DTOs/ │ │ ├── SendMessageRequest.cs │ │ └── SendMessageResponse.cs │ ├── Validators/SendMessageRequestValidator.cs │ ├── Services/ │ │ ├── ISentMessageService.cs │ │ └── SentMessageService.cs │ ├── Configuration/SentOptions.cs │ ├── Filters/GlobalExceptionFilter.cs │ ├── MinimalApis/MessageEndpoints.cs │ └── Program.cs ├── tests/SentMessageServiceTests.cs └── appsettings.json ``` ## Configuration ```csharp // Configuration/SentOptions.cs public class SentOptions { public const string SectionName = "Sent"; [Required] public string ApiKey { get; set; } = string.Empty; public string? WebhookSecret { get; set; } public string BaseUrl { get; set; } = "https://api.sent.dm"; [Range(1, 300)] public int TimeoutSeconds { get; set; } = 30; [Range(0, 10)] public int MaxRetries { get; set; } = 3; } ``` ## Dependency Injection Setup ```csharp // Program.cs using MyApp.Configuration; using MyApp.Filters; using MyApp.MinimalApis; using MyApp.Services; using MyApp.Validators; using SentDM.Extensions; var builder = WebApplication.CreateBuilder(args); // Configure options builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(SentOptions.SectionName)) .ValidateDataAnnotations() .ValidateOnStart(); // Add Sent client builder.Services.AddSentDM((sp, options) => { var opts = sp.GetRequiredService>().Value; options.ApiKey = opts.ApiKey; options.BaseUrl = opts.BaseUrl; options.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds); options.MaxRetries = opts.MaxRetries; }); // Services builder.Services.AddScoped(); builder.Services.AddValidatorsFromAssemblyContaining(); // API Versioning builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; }) .AddMvc() .AddApiExplorer(options => { options.GroupNameFormat = "'v'VVV"; options.SubstituteApiVersionInUrl = true; }); // Swagger & Health Checks builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddHealthChecks(); // Rate Limiting builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter("sent-api", limiterOptions => { limiterOptions.PermitLimit = 100; limiterOptions.Window = TimeSpan.FromMinutes(1); }); }); // Controllers with global exception filter builder.Services.AddControllers(options => options.Filters.Add()); builder.Services.AddProblemDetails(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseRateLimiter(); app.UseAuthorization(); app.MapHealthChecks("/health"); app.MapMessageEndpoints(); app.MapControllers(); app.Run(); ``` ## Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `SENT_DM_API_KEY` | Your Sent DM API key (required) | - | | `SENT_DM_WEBHOOK_SECRET` | Webhook signature secret | `null` | | `SENT_DM_BASE_URL` | API base URL | `https://api.sent.dm` | | `SENT_DM_TIMEOUT_SECONDS` | Request timeout | `30` | | `SENT_DM_MAX_RETRIES` | Max retry attempts | `3` | ```bash # Linux/macOS export SENT_DM_API_KEY="sent_your_api_key_here" export ASPNETCORE_ENVIRONMENT="Development" # Windows PowerShell $env:SENT_DM_API_KEY="sent_your_api_key_here" ``` ## DTOs with Validation ```csharp // DTOs/SendMessageRequest.cs public class SendMessageRequest { [Required, Phone, StringLength(20, MinimumLength = 10)] public string PhoneNumber { get; set; } = string.Empty; [Required, StringLength(100)] public string TemplateId { get; set; } = string.Empty; public Dictionary? Variables { get; set; } public List? Channels { get; set; } } // DTOs/SendMessageResponse.cs public class SendMessageResponse { public string MessageId { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public decimal? Price { get; set; } public DateTime SentAt { get; set; } } ``` ## FluentValidation ```csharp // Validators/SendMessageRequestValidator.cs public class SendMessageRequestValidator : AbstractValidator { private static readonly string[] ValidChannels = ["whatsapp", "sms", "telegram"]; public SendMessageRequestValidator() { RuleFor(x => x.PhoneNumber) .NotEmpty() .Matches(@"^\+[1-9]\d{1,14}$") .WithMessage("Phone number must be in E.164 format"); RuleFor(x => x.TemplateId).NotEmpty(); RuleForEach(x => x.Channels) .Must(c => ValidChannels.Contains(c?.ToLower())) .When(x => x.Channels != null); } } ``` ## Service Layer ```csharp // Services/ISentMessageService.cs public interface ISentMessageService { Task SendMessageAsync( SendMessageRequest request, CancellationToken ct = default); Task SendWelcomeMessageAsync( string phoneNumber, string? name = null, CancellationToken ct = default); } // Services/SentMessageService.cs public class SentMessageService : ISentMessageService { private readonly ISentClient _sentClient; private readonly ILogger _logger; public SentMessageService(ISentClient sentClient, ILogger logger) { _sentClient = sentClient; _logger = logger; } public async Task SendMessageAsync( SendMessageRequest request, CancellationToken ct = default) { var messageRequest = new SendMessageRequest { PhoneNumber = request.PhoneNumber, TemplateId = request.TemplateId, Channel = request.Channels?.FirstOrDefault() ?? "whatsapp", Variables = request.Variables ?? new Dictionary() }; var result = await _sentClient.Messages.SendAsync(messageRequest, ct); if (!result.Success) throw new SentException($"Failed to send message: {result.Error?.Message}", result.Error); _logger.LogInformation("Message sent: {MessageId}", result.Data.Id); return new SendMessageResponse { MessageId = result.Data.Id, Status = result.Data.Status, Price = result.Data.Price, SentAt = DateTime.UtcNow }; } public async Task SendWelcomeMessageAsync( string phoneNumber, string? name = null, CancellationToken ct = default) { var request = new SendMessageRequest { PhoneNumber = phoneNumber, TemplateId = "welcome-template", Channel = "whatsapp", Variables = new Dictionary { ["name"] = name ?? "Valued Customer" } }; var result = await _sentClient.Messages.SendAsync(request, ct); return new SendMessageResponse { MessageId = result.Data.Id, Status = result.Data.Status, SentAt = DateTime.UtcNow }; } } ``` ## Exception Filter ```csharp // Filters/GlobalExceptionFilter.cs public class GlobalExceptionFilter : IExceptionFilter { private readonly ILogger _logger; private readonly IHostEnvironment _environment; public GlobalExceptionFilter(ILogger logger, IHostEnvironment environment) { _logger = logger; _environment = environment; } public void OnException(ExceptionContext context) { var exception = context.Exception; var traceId = context.HttpContext.TraceIdentifier; _logger.LogError(exception, "Unhandled exception. TraceId: {TraceId}", traceId); var problemDetails = exception switch { SentException sentEx => CreateSentProblemDetails(sentEx, traceId), ValidationException valEx => CreateValidationProblemDetails(valEx, traceId), _ => CreateGenericProblemDetails(exception, traceId) }; context.Result = new ObjectResult(problemDetails) { StatusCode = problemDetails.Status }; context.ExceptionHandled = true; } private static ProblemDetails CreateSentProblemDetails(SentException exception, string traceId) { var statusCode = exception.Error?.Code switch { "invalid_api_key" => StatusCodes.Status401Unauthorized, "insufficient_credits" => StatusCodes.Status402PaymentRequired, "rate_limited" => StatusCodes.Status429TooManyRequests, "invalid_phone_number" => StatusCodes.Status400BadRequest, "template_not_found" => StatusCodes.Status404NotFound, _ => StatusCodes.Status500InternalServerError }; return new ProblemDetails { Status = statusCode, Title = "Message Service Error", Detail = exception.Message, Extensions = { ["traceId"] = traceId, ["errorCode"] = exception.Error?.Code } }; } private static ProblemDetails CreateValidationProblemDetails(ValidationException exception, string traceId) { return new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Validation Failed", Detail = exception.Message, Extensions = { ["traceId"] = traceId } }; } private ProblemDetails CreateGenericProblemDetails(Exception exception, string traceId) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Internal Server Error", Extensions = { ["traceId"] = traceId } }; if (_environment.IsDevelopment()) { problemDetails.Detail = exception.Message; problemDetails.Extensions["stackTrace"] = exception.StackTrace; } else { problemDetails.Detail = "An unexpected error occurred. Please try again later."; } return problemDetails; } } ``` ## Minimal API Endpoints ```csharp // MinimalApis/MessageEndpoints.cs public static class MessageEndpoints { public static IEndpointRouteBuilder MapMessageEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v{version:apiVersion}/messages") .WithApiVersionSet() .HasApiVersion(1.0) .WithTags("Messages") .WithRateLimiter("sent-api") .WithOpenApi(); group.MapPost("/send", async ( SendMessageRequest request, ISentMessageService messageService, IValidator validator, CancellationToken ct) => { var validationResult = await validator.ValidateAsync(request, ct); if (!validationResult.IsValid) return Results.ValidationProblem(validationResult.ToDictionary()); var response = await messageService.SendMessageAsync(request, ct); return Results.Ok(response); }) .WithName("SendMessage") .Produces(StatusCodes.Status200OK) .ProducesValidationProblem(); group.MapPost("/welcome", async ( [FromBody] WelcomeMessageRequest request, ISentMessageService messageService, CancellationToken ct) => { var response = await messageService.SendWelcomeMessageAsync(request.PhoneNumber, request.Name, ct); return Results.Ok(response); }) .WithName("SendWelcomeMessage") .Produces(StatusCodes.Status200OK); return app; } } ``` ## MVC Controller ```csharp // Controllers/MessagesController.cs [ApiController] [Route("api/v{version:apiVersion}/[controller]")] [ApiVersion("1.0")] [Produces("application/json")] public class MessagesController : ControllerBase { private readonly ISentMessageService _messageService; public MessagesController(ISentMessageService messageService) { _messageService = messageService; } [HttpPost("send")] [ProducesResponseType(typeof(SendMessageResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task> SendMessage( [FromBody] SendMessageRequest request, CancellationToken cancellationToken) { var response = await _messageService.SendMessageAsync(request, cancellationToken); return Ok(response); } [HttpPost("welcome")] [ProducesResponseType(typeof(SendMessageResponse), StatusCodes.Status200OK)] public async Task> SendWelcome( [FromBody] WelcomeMessageRequest request, CancellationToken cancellationToken) { var response = await _messageService.SendWelcomeMessageAsync( request.PhoneNumber, request.Name, cancellationToken); return Ok(response); } } ``` ## Testing ```csharp // tests/SentMessageServiceTests.cs public class SentMessageServiceTests { private readonly Mock _mockSentClient; private readonly Mock> _mockLogger; private readonly SentMessageService _service; public SentMessageServiceTests() { _mockSentClient = new Mock(); _mockLogger = new Mock>(); _service = new SentMessageService(_mockSentClient.Object, _mockLogger.Object); } [Fact] public async Task SendMessageAsync_WithValidRequest_ReturnsSuccessResponse() { var request = new SendMessageRequest { PhoneNumber = "+1234567890", TemplateId = "welcome-template" }; var mockResult = new SentResult { Success = true, Data = new MessageResponse { Id = "msg_123", Status = "queued", Price = 0.05m } }; _mockSentClient .Setup(x => x.Messages.SendAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(mockResult); var result = await _service.SendMessageAsync(request); Assert.Equal("msg_123", result.MessageId); Assert.Equal("queued", result.Status); Assert.Equal(0.05m, result.Price); } [Fact] public async Task SendMessageAsync_WithFailedResult_ThrowsSentException() { var request = new SendMessageRequest { PhoneNumber = "+1234567890", TemplateId = "invalid-template" }; var mockResult = new SentResult { Success = false, Error = new SentError { Code = "template_not_found", Message = "Template not found" } }; _mockSentClient .Setup(x => x.Messages.SendAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(mockResult); var exception = await Assert.ThrowsAsync(() => _service.SendMessageAsync(request)); Assert.Contains("Template not found", exception.Message); } } ``` ## Configuration Files ```json // appsettings.json { "Sent": { "ApiKey": "", "WebhookSecret": "", "BaseUrl": "https://api.sent.dm", "TimeoutSeconds": 30, "MaxRetries": 3 }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" } ``` ```json // appsettings.Development.json { "Sent": { "ApiKey": "dev_api_key_here", "WebhookSecret": "dev_webhook_secret" }, "Logging": { "LogLevel": { "Default": "Debug" } } } ``` ## Required NuGet Packages ```xml ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [C# SDK reference](/sdks/csharp) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/go.txt TITLE: Go SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/go.txt Official Go SDK for Sent. Lightweight, fast, and context-aware with minimal dependencies. # Go SDK The official Go SDK for Sent LogoSent provides a lightweight, high-performance client with minimal dependencies. Built for microservices, CLI tools, and high-throughput applications with full context support. ## Requirements This library requires Go 1.22 or later. ## Installation ```bash go get github.com/sentdm/sent-dm-go ``` To pin a specific version: ```bash go get github.com/sentdm/sent-dm-go@v0.17.0 ``` ## Quick Start ### Initialize the client ```go package main import ( "context" "os" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) func main() { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")), // defaults to os.LookupEnv("SENT_DM_API_KEY") ) // Use the client... } ``` ### Send your first message ```go package main import ( "context" "fmt" "log" "os" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) func main() { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")), ) ctx := context.Background() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), Parameters: map[string]string{ "name": "John Doe", "order_id": "12345", }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Message sent: %s\n", response.Data.Messages[0].ID) fmt.Printf("Status: %s\n", response.Data.Messages[0].Status) } ``` ## Authentication The client can be configured using environment variables or explicitly using functional options: ```go import ( "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) // Using environment variables (SENT_DM_API_KEY) client := sentdm.NewClient() // Or explicit configuration client := sentdm.NewClient( option.WithAPIKey("your_api_key"), ) ``` ## Request fields The sentdm library uses the `omitzero` semantics from Go 1.24+ `encoding/json` release for request fields. Required primitive fields feature the tag `json:"...,required"`. These fields are always serialized, even their zero values. Optional primitive types are wrapped in a `param.Opt[T]`. These fields can be set with the provided constructors, `sentdm.String()`, `sentdm.Int()`, etc. ```go params := sentdm.MessageSendParams{ To: []string{"+1234567890"}, // required property Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("template-id"), Name: sentdm.String("welcome"), }, Channel: sentdm.StringSlice([]string{"whatsapp"}), // optional property } ``` To send `null` instead of a `param.Opt[T]`, use `param.Null[T]()`. To check if a field is omitted, use `param.IsOmitted()`. ## Send Messages ### Send a message ```go response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), Parameters: map[string]string{ "name": "John Doe", "order_id": "12345", }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Sent: %s\n", response.Data.Messages[0].ID) fmt.Printf("Status: %s\n", response.Data.Messages[0].Status) ``` ### Test mode Use `TestMode` to validate requests without sending real messages: ```go response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, TestMode: sentdm.Bool(true), // Validates but doesn't send }) if err != nil { log.Fatal(err) } // Response will have test data fmt.Printf("Validation passed: %s\n", response.Data.Messages[0].ID) ``` ## Error handling When the API returns a non-success status code, we return an error of type `*sentdm.Error`: ```go response, err := client.Messages.Send(ctx, params) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { fmt.Printf("API error: %s\n", apiErr.Message) fmt.Printf("Status: %d\n", apiErr.StatusCode) } else { fmt.Printf("Other error: %v\n", err) } } ``` ## Pagination For paginated list endpoints, you can use `.ListAutoPaging()` methods to iterate through items across all pages: ```go // List all contacts iter := client.Contacts.ListAutoPaging(ctx, sentdm.ContactListParams{}) for iter.Next() { contact := iter.Current() fmt.Printf("%s - %s\n", contact.PhoneNumber, contact.AvailableChannels) } if err := iter.Err(); err != nil { log.Fatal(err) } ``` Or use simple `.List()` methods to fetch a single page: ```go page, err := client.Contacts.List(ctx, sentdm.ContactListParams{ Limit: sentdm.Int(100), }) if err != nil { log.Fatal(err) } for _, contact := range page.Data { fmt.Printf("%s\n", contact.PhoneNumber) } // Get next page if page.HasMore { nextPage, err := page.GetNextPage() // ... } ``` ## Contacts Create and manage contacts: ```go // Create a contact response, err := client.Contacts.Create(ctx, sentdm.ContactCreateParams{ PhoneNumber: "+1234567890", }) if err != nil { log.Fatal(err) } fmt.Printf("Contact ID: %s\n", response.Data.ID) // List contacts page, err := client.Contacts.List(ctx, sentdm.ContactListParams{ Limit: sentdm.Int(100), }) // Get a contact contact, err := client.Contacts.Get(ctx, "contact-uuid") // Update a contact response, err = client.Contacts.Update(ctx, "contact-uuid", sentdm.ContactUpdateParams{ PhoneNumber: sentdm.String("+1987654321"), }) // Delete a contact err = client.Contacts.Delete(ctx, "contact-uuid") ``` ## Templates List and retrieve templates: ```go // List templates templates, err := client.Templates.List(ctx, sentdm.TemplateListParams{}) if err != nil { log.Fatal(err) } for _, template := range templates.Data { fmt.Printf("%s (%s): %s\n", template.Name, template.Status, template.ID) } // Get a template template, err := client.Templates.Get(ctx, "template-uuid") fmt.Printf("Name: %s\n", template.Name) fmt.Printf("Status: %s\n", template.Status) ``` ## RequestOptions This library uses the functional options pattern. Functions defined in the `option` package return a `RequestOption`, which is a closure that mutates a `RequestConfig`. These options can be supplied to the client or at individual requests: ```go client := sentdm.NewClient( // Adds a header to every request made by the client option.WithHeader("X-Some-Header", "custom_header_info"), ) // Override per-request response, err := client.Messages.Send(ctx, params, option.WithHeader("X-Some-Header", "some_other_value"), option.WithJSONSet("custom.field", map[string]string{"my": "object"}), ) ``` The request option `option.WithDebugLog(nil)` may be helpful while debugging. See the [full list of request options](https://pkg.go.dev/github.com/sentdm/sent-dm-go/option). ## Gin Framework ```go package main import ( "context" "net/http" "os" "time" "github.com/gin-gonic/gin" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) func main() { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")), ) r := gin.Default() r.POST("/send", func(c *gin.Context) { var req struct { To []string `json:"to"` Template sentdm.MessageSendParamsTemplate `json:"template"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: req.To, Template: req.Template, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "status": "sent", "message_id": response.Data.Messages[0].ID, }) }) r.Run(":8080") } ``` ## Echo Framework ```go package main import ( "context" "net/http" "os" "time" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) func main() { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")), ) e := echo.New() e.POST("/send", func(c echo.Context) error { req := new(sentdm.MessageSendParams) if err := c.Bind(req); err != nil { return err } ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second) defer cancel() response, err := client.Messages.Send(ctx, *req) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": err.Error(), }) } return c.JSON(http.StatusOK, map[string]string{ "status": "sent", "message_id": response.Data.Messages[0].ID, }) }) e.Logger.Fatal(e.Start(":8080")) } ``` ## Response objects All fields in response structs are ordinary value types. Response structs also include a special `JSON` field containing metadata about each property. ```go response, err := client.Templates.Get(ctx, "template-uuid") if err != nil { log.Fatal(err) } fmt.Println(response.Name) // Access the field directly // Check if field was present in response if response.JSON.Name.Valid() { fmt.Println("Name was present") } // Access raw JSON fmt.Println(response.JSON.Name.Raw()) ``` ## Concurrent Sending Leverage Go's concurrency for high-throughput: ```go func sendBulkMessages( client *sentdm.Client, phoneNumbers []string, templateID string, ) error { var wg sync.WaitGroup errChan := make(chan error, len(phoneNumbers)) // Semaphore to limit concurrency sem := make(chan struct{}, 10) for _, phone := range phoneNumbers { wg.Add(1) go func(p string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{p}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String(templateID), }, }) if err != nil { errChan <- fmt.Errorf("failed to send to %s: %w", p, err) } }(phone) } wg.Wait() close(errChan) var errs []error for err := range errChan { errs = append(errs, err) } if len(errs) > 0 { return fmt.Errorf("failed to send %d messages", len(errs)) } return nil } ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "math" "net/http" "os" "strconv" "strings" "time" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } webhookID := r.Header.Get("X-Webhook-ID") timestamp := r.Header.Get("X-Webhook-Timestamp") signature := r.Header.Get("X-Webhook-Signature") // 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" raw := os.Getenv("SENT_WEBHOOK_SECRET") // "whsec_abc123..." keyStr := strings.TrimPrefix(raw, "whsec_") keyBytes, _ := base64.StdEncoding.DecodeString(keyStr) signed := webhookID + "." + timestamp + "." + string(body) mac := hmac.New(sha256.New, keyBytes) mac.Write([]byte(signed)) expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(signature), []byte(expected)) { http.Error(w, `{"error":"invalid signature"}`, http.StatusUnauthorized) return } // 2. Optional: reject replayed events older than 5 minutes ts, _ := strconv.ParseInt(timestamp, 10, 64) if math.Abs(float64(time.Now().Unix()-ts)) > 300 { http.Error(w, `{"error":"timestamp too old"}`, http.StatusUnauthorized) return } var event struct { Field string `json:"field"` Payload map[string]any `json:"payload"` } json.Unmarshal(body, &event) // 3. Handle events — update message status in your own database if event.Field == "messages" { messageID := event.Payload["message_id"] status := event.Payload["message_status"] // db.UpdateMessageStatus(ctx, messageID.(string), status.(string)) _ = messageID; _ = status } // 4. Always return 200 quickly w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"received":true}`)) } ``` See the [Webhooks reference](/reference/api/webhooks) for the full payload schema and all status values. ## Source & Issues - **Version**: 0.17.0 - **GitHub**: [sentdm/sent-dm-go](https://github.com/sentdm/sent-dm-go) - **GoDoc**: [pkg.go.dev/github.com/sentdm/sent-dm-go](https://pkg.go.dev/github.com/sentdm/sent-dm-go) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-go/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/go/integrations/echo.txt TITLE: Echo Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/go/integrations/echo.txt Production-ready Echo v4 integration with Go 1.22+ best practices # Echo Integration Production-ready integration with the Echo web framework featuring structured logging, request validation, graceful shutdown, and comprehensive error handling. This example uses `github.com/sentdm/sent-dm-go` with Echo v4 patterns including middleware, service layer, and clean architecture principles. ## Project Structure ``` sent-echo-app/ ├── cmd/api/main.go # Application entry ├── internal/ │ ├── config/config.go # Configuration │ ├── handler/ │ │ ├── health.go # Health endpoints │ │ ├── message.go # Message handlers │ │ └── webhook.go # Webhook handlers │ ├── middleware/ │ │ ├── logger.go # Request logging │ │ └── error.go # Error handling │ ├── model/request.go # Request DTOs │ ├── model/response.go # Response models │ ├── service/ │ │ ├── message.go # Message logic │ │ └── webhook.go # Webhook processing │ └── validator/validator.go # Validation ├── pkg/sentclient/client.go # Sent client wrapper ├── docker-compose.yml ├── Dockerfile ├── go.mod └── .env ``` ## Configuration ```go // internal/config/config.go package config import ( "fmt" "time" "github.com/kelseyhightower/envconfig" ) type Config struct { Server ServerConfig Sent SentConfig Log LogConfig RateLimit RateLimitConfig } type ServerConfig struct { Port string `envconfig:"PORT" default:"8080"` ReadTimeout time.Duration `envconfig:"SERVER_READ_TIMEOUT" default:"5s"` WriteTimeout time.Duration `envconfig:"SERVER_WRITE_TIMEOUT" default:"10s"` IdleTimeout time.Duration `envconfig:"SERVER_IDLE_TIMEOUT" default:"120s"` Environment string `envconfig:"ENVIRONMENT" default:"development"` } type SentConfig struct { APIKey string `envconfig:"SENT_DM_API_KEY" required:"true"` WebhookSecret string `envconfig:"SENT_DM_WEBHOOK_SECRET" required:"true"` } type LogConfig struct { Level string `envconfig:"LOG_LEVEL" default:"info"` Format string `envconfig:"LOG_FORMAT" default:"json"` } type RateLimitConfig struct { RequestsPerSecond float64 `envconfig:"RATE_LIMIT_RPS" default:"10"` BurstSize int `envconfig:"RATE_LIMIT_BURST" default:"20"` } func Load() (*Config, error) { var cfg Config if err := envconfig.Process("", &cfg); err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } return &cfg, nil } func NewLogger(cfg LogConfig) (*zap.Logger, error) { level, _ := zapcore.ParseLevel(cfg.Level) encoderConfig := zapcore.EncoderConfig{ TimeKey: "timestamp", LevelKey: "level", MessageKey: "msg", EncodeLevel: zapcore.LowercaseLevelEncoder, EncodeTime: zapcore.ISO8601TimeEncoder, } var encoder zapcore.Encoder if cfg.Format == "console" { encoder = zapcore.NewConsoleEncoder(encoderConfig) } else { encoder = zapcore.NewJSONEncoder(encoderConfig) } core := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), level) return zap.New(core, zap.AddCaller()), nil } ``` ## Models ```go // internal/model/response.go package model import "net/http" type ErrorResponse struct { Success bool `json:"success"` Error ErrorInfo `json:"error,omitempty"` RequestID string `json:"requestId,omitempty"` } type ErrorInfo struct { Code string `json:"code"` Message string `json:"message"` Details string `json:"details,omitempty"` } type SuccessResponse[T any] struct { Success bool `json:"success"` Data T `json:"data,omitempty"` } func NewSuccessResponse[T any](data T) SuccessResponse[T] { return SuccessResponse[T]{Success: true, Data: data} } func NewErrorResponse(code, message string) ErrorResponse { return ErrorResponse{Success: false, Error: ErrorInfo{Code: code, Message: message}} } type HTTPError struct { Code int ErrorCode string Message string InnerError error } func (e *HTTPError) Error() string { return e.Message } func (e *HTTPError) Unwrap() error { return e.InnerError } var ( ErrBadRequest = &HTTPError{Code: http.StatusBadRequest, ErrorCode: "BAD_REQUEST", Message: "Invalid request"} ErrUnauthorized = &HTTPError{Code: http.StatusUnauthorized, ErrorCode: "UNAUTHORIZED", Message: "Authentication required"} ErrValidation = &HTTPError{Code: http.StatusUnprocessableEntity, ErrorCode: "VALIDATION_ERROR", Message: "Validation failed"} ) func BindValidationError(err error) *HTTPError { return &HTTPError{Code: http.StatusBadRequest, ErrorCode: "VALIDATION_ERROR", Message: "Request validation failed", Details: err.Error(), InnerError: err} } // internal/model/request.go type SendMessageRequest struct { To []string `json:"to" validate:"required,min=1,dive,e164"` Template TemplateRequest `json:"template" validate:"required"` Channels []string `json:"channels,omitempty" validate:"omitempty,dive,oneof=sms whatsapp email telegram"` } type TemplateRequest struct { ID string `json:"id,omitempty" validate:"omitempty,uuid"` Name string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` Parameters map[string]string `json:"parameters,omitempty"` } type WelcomeMessageRequest struct { PhoneNumber string `json:"phoneNumber" validate:"required,e164"` Name string `json:"name,omitempty" validate:"omitempty,max=100"` } ``` ## Sent Client ```go // pkg/sentclient/client.go package sentclient import ( "context" "fmt" "time" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" "go.uber.org/zap" ) type Client struct { client *sentdm.Client logger *zap.Logger } func New(apiKey string, logger *zap.Logger) *Client { return &Client{ client: sentdm.NewClient(option.WithAPIKey(apiKey)), logger: logger.Named("sent-client"), } } type SendMessageResult struct { MessageID string Status string Channel string } func (c *Client) SendMessage(ctx context.Context, to []string, template sentdm.MessageSendParamsTemplate, channels []string) (*SendMessageResult, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() params := sentdm.MessageSendParams{To: to, Template: template} if len(channels) > 0 { params.Channels = sentdm.StringSlice(channels) } c.logger.Debug("sending message", zap.Strings("to", to), zap.Strings("channels", channels)) response, err := c.client.Messages.Send(ctx, params) if err != nil { c.logger.Error("failed to send message", zap.Error(err)) return nil, fmt.Errorf("failed to send message: %w", err) } if len(response.Data.Messages) == 0 { return nil, fmt.Errorf("no messages in response") } msg := response.Data.Messages[0] return &SendMessageResult{MessageID: msg.ID, Status: msg.Status, Channel: msg.Channel}, nil } func (c *Client) ParseEvent(body string) (*sentdm.WebhookEvent, error) { return c.client.Webhooks.ParseEvent(body) } func (c *Client) VerifySignature(body, signature, secret string) bool { return c.client.Webhooks.VerifySignature(body, signature, secret) } ``` ## Middleware ```go // internal/middleware/logger.go package middleware import ( "time" "github.com/labstack/echo/v4" "go.uber.org/zap" ) func Logger(logger *zap.Logger) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { start := time.Now() err := next(c) logger.Info("request", zap.String("method", c.Request().Method), zap.String("path", c.Request().URL.Path), zap.Int("status", c.Response().Status), zap.Duration("latency", time.Since(start)), ) return err } } } // internal/middleware/error.go package middleware import ( "errors" "net/http" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/model" "go.uber.org/zap" ) func ErrorHandler(logger *zap.Logger) echo.HTTPErrorHandler { return func(err error, c echo.Context) { if c.Response().Committed { return } requestID := c.Response().Header().Get(echo.HeaderXRequestID) var httpErr *model.HTTPError if errors.As(err, &httpErr) { c.JSON(httpErr.Code, model.ErrorResponse{Success: false, RequestID: requestID, Error: model.ErrorInfo{Code: httpErr.ErrorCode, Message: httpErr.Message, Details: httpErr.Details}}) return } var echoErr *echo.HTTPError if errors.As(err, &echoErr) { c.JSON(echoErr.Code, model.NewErrorResponse("ERROR", echoErr.Error())) return } logger.Error("unexpected error", zap.Error(err), zap.String("requestId", requestID)) c.JSON(http.StatusInternalServerError, model.NewErrorResponse("INTERNAL_ERROR", "An unexpected error occurred")) } } ``` ## Service Layer ```go // internal/service/message.go package service import ( "context" "fmt" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/model" "github.com/sentdm/sent-dm-go/sent-echo-app/pkg/sentclient" "go.uber.org/zap" ) type MessageService struct { sentClient *sentclient.Client logger *zap.Logger } func NewMessageService(sentClient *sentclient.Client, logger *zap.Logger) *MessageService { return &MessageService{sentClient: sentClient, logger: logger.Named("message-service")} } type SendMessageResult struct { MessageID string `json:"messageId"` Status string `json:"status"` Channel string `json:"channel,omitempty"` } func (s *MessageService) SendMessage(ctx context.Context, req *model.SendMessageRequest) (*SendMessageResult, error) { template := sentdm.MessageSendParamsTemplate{ID: sentdm.String(req.Template.ID), Name: sentdm.String(req.Template.Name), Parameters: req.Template.Parameters} result, err := s.sentClient.SendMessage(ctx, req.To, template, req.Channels) if err != nil { return nil, fmt.Errorf("failed to send message: %w", err) } return &SendMessageResult{MessageID: result.MessageID, Status: result.Status, Channel: result.Channel}, nil } func (s *MessageService) SendWelcomeMessage(ctx context.Context, phoneNumber, name string) (*SendMessageResult, error) { if name == "" { name = "Valued Customer" } template := sentdm.MessageSendParamsTemplate{ID: sentdm.String("welcome-template-id"), Name: sentdm.String("welcome"), Parameters: map[string]string{"name": name}} result, err := s.sentClient.SendMessage(ctx, []string{phoneNumber}, template, []string{"whatsapp"}) if err != nil { return nil, err } s.logger.Info("welcome message sent", zap.String("phoneNumber", phoneNumber), zap.String("messageId", result.MessageID)) return &SendMessageResult{MessageID: result.MessageID, Status: result.Status}, nil } // internal/service/webhook.go package service import ( "context" "fmt" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/sent-echo-app/pkg/sentclient" "go.uber.org/zap" ) type WebhookService struct { sentClient *sentclient.Client logger *zap.Logger secret string } func NewWebhookService(sentClient *sentclient.Client, logger *zap.Logger, secret string) *WebhookService { return &WebhookService{sentClient: sentClient, logger: logger.Named("webhook-service"), secret: secret} } func (s *WebhookService) VerifySignature(body, signature string) bool { if signature == "" { s.logger.Warn("missing webhook signature"); return false } return s.sentClient.VerifySignature(body, signature, s.secret) } func (s *WebhookService) ProcessEvent(ctx context.Context, body string) error { event, err := s.sentClient.ParseEvent(body) if err != nil { return fmt.Errorf("failed to parse event: %w", err) } s.logger.Info("processing webhook event", zap.String("type", event.Type), zap.String("eventId", event.ID)) switch event.Type { case "message.delivered": s.logger.Info("message delivered", zap.String("messageId", event.Data.MessageID)) case "message.failed": s.logger.Error("message failed", zap.String("messageId", event.Data.MessageID), zap.String("error", event.Data.Error.Message)) case "message.status.updated": s.logger.Info("message status updated", zap.String("messageId", event.Data.MessageID), zap.String("status", event.Data.Status)) default: s.logger.Warn("unhandled event type", zap.String("type", event.Type)) } return nil } ``` ## Handlers ```go // internal/handler/health.go package handler import ( "net/http" "time" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/model" ) type HealthHandler struct{ startTime time.Time } func NewHealthHandler() *HealthHandler { return &HealthHandler{startTime: time.Now()} } func (h *HealthHandler) Register(e *echo.Echo) { e.GET("/health", func(c echo.Context) error { return c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]interface{}{ "status": "healthy", "uptime": time.Since(h.startTime).String(), "timestamp": time.Now().Unix(), })) }) e.GET("/ready", func(c echo.Context) error { return c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]interface{}{"status": "ready"})) }) } // internal/handler/message.go package handler import ( "net/http" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/model" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/service" "go.uber.org/zap" ) type MessageHandler struct { messageService *service.MessageService logger *zap.Logger } func NewMessageHandler(messageService *service.MessageService, logger *zap.Logger) *MessageHandler { return &MessageHandler{messageService: messageService, logger: logger.Named("message-handler")} } func (h *MessageHandler) Register(e *echo.Echo) { g := e.Group("/api/v1/messages") g.POST("/send", h.SendMessage) g.POST("/welcome", h.SendWelcome) } func (h *MessageHandler) SendMessage(c echo.Context) error { var req model.SendMessageRequest if err := c.Bind(&req); err != nil { return model.BindValidationError(err) } if err := c.Validate(&req); err != nil { return model.BindValidationError(err) } result, err := h.messageService.SendMessage(c.Request().Context(), &req) if err != nil { h.logger.Error("failed to send message", zap.Error(err)) return err } return c.JSON(http.StatusOK, model.NewSuccessResponse(result)) } func (h *MessageHandler) SendWelcome(c echo.Context) error { var req model.WelcomeMessageRequest if err := c.Bind(&req); err != nil { return model.BindValidationError(err) } if err := c.Validate(&req); err != nil { return model.BindValidationError(err) } result, err := h.messageService.SendWelcomeMessage(c.Request().Context(), req.PhoneNumber, req.Name) if err != nil { h.logger.Error("failed to send welcome message", zap.Error(err)) return err } return c.JSON(http.StatusOK, model.NewSuccessResponse(result)) } // internal/handler/webhook.go package handler import ( "io" "net/http" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/model" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/service" "go.uber.org/zap" ) type WebhookHandler struct { webhookService *service.WebhookService logger *zap.Logger } func NewWebhookHandler(webhookService *service.WebhookService, logger *zap.Logger) *WebhookHandler { return &WebhookHandler{webhookService: webhookService, logger: logger.Named("webhook-handler")} } func (h *WebhookHandler) Register(e *echo.Echo) { e.POST("/webhooks/sent", h.HandleWebhook) } func (h *WebhookHandler) HandleWebhook(c echo.Context) error { body, err := io.ReadAll(c.Request().Body) if err != nil { return model.BindValidationError(err) } signature := c.Request().Header.Get("X-Webhook-Signature") if !h.webhookService.VerifySignature(string(body), signature) { h.logger.Warn("invalid webhook signature", zap.String("ip", c.RealIP())) return model.ErrUnauthorized } if err := h.webhookService.ProcessEvent(c.Request().Context(), string(body)); err != nil { h.logger.Error("failed to process webhook", zap.Error(err)) return &model.HTTPError{Code: http.StatusBadRequest, ErrorCode: "PROCESSING_ERROR", Message: "Failed to process webhook", Details: err.Error()} } return c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]bool{"received": true})) } ``` ## Main Application ```go // cmd/api/main.go package main import ( "context" "net/http" "os" "os/signal" "syscall" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/config" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/handler" appmiddleware "github.com/sentdm/sent-dm-go/sent-echo-app/internal/middleware" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/service" appvalidator "github.com/sentdm/sent-dm-go/sent-echo-app/internal/validator" "github.com/sentdm/sent-dm-go/sent-echo-app/pkg/sentclient" "go.uber.org/zap" ) func main() { cfg, err := config.Load() if err != nil { panic(err) } logger, err := config.NewLogger(cfg.Log) if err != nil { panic(err) } defer logger.Sync() sentClient := sentclient.New(cfg.Sent.APIKey, logger) messageService := service.NewMessageService(sentClient, logger) webhookService := service.NewWebhookService(sentClient, logger, cfg.Sent.WebhookSecret) e := echo.New() e.HideBanner = true e.Validator = appvalidator.New() e.HTTPErrorHandler = appmiddleware.ErrorHandler(logger) e.Use(middleware.RequestID()) e.Use(appmiddleware.Logger(logger)) e.Use(middleware.Recover()) e.Use(middleware.CORS()) e.Use(middleware.Secure()) e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(cfg.RateLimit.RequestsPerSecond))) handler.NewHealthHandler().Register(e) handler.NewMessageHandler(messageService, logger).Register(e) handler.NewWebhookHandler(webhookService, logger).Register(e) srv := &http.Server{ Addr: ":" + cfg.Server.Port, Handler: e, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, } go func() { logger.Info("starting server", zap.String("port", cfg.Server.Port)) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Fatal("server failed", zap.Error(err)) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit logger.Info("shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { logger.Fatal("forced shutdown", zap.Error(err)) } logger.Info("server exited") } ``` ## Testing ```go // internal/handler/message_test.go package handler import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "github.com/labstack/echo/v4" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/model" "github.com/sentdm/sent-dm-go/sent-echo-app/internal/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.uber.org/zap" ) type MockMessageService struct{ mock.Mock } func (m *MockMessageService) SendMessage(ctx context.Context, req *model.SendMessageRequest) (*service.SendMessageResult, error) { args := m.Called(ctx, req) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*service.SendMessageResult), args.Error(1) } func (m *MockMessageService) SendWelcomeMessage(ctx context.Context, phoneNumber, name string) (*service.SendMessageResult, error) { args := m.Called(ctx, phoneNumber, name) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*service.SendMessageResult), args.Error(1) } func TestMessageHandler_SendMessage(t *testing.T) { tests := []struct { name string body interface{} mockSetup func(*MockMessageService) wantStatus int wantSuccess bool }{ { name: "success", body: model.SendMessageRequest{To: []string{"+1234567890"}, Template: model.TemplateRequest{ID: "tpl-123", Name: "welcome"}}, mockSetup: func(m *MockMessageService) { m.On("SendMessage", mock.Anything, mock.Anything).Return(&service.SendMessageResult{MessageID: "msg_123", Status: "pending"}, nil) }, wantStatus: 200, wantSuccess: true, }, { name: "invalid body", body: "invalid", mockSetup: func(m *MockMessageService) {}, wantStatus: 400, wantSuccess: false, }, { name: "service error", body: model.SendMessageRequest{To: []string{"+1234567890"}, Template: model.TemplateRequest{ID: "tpl-123"}}, mockSetup: func(m *MockMessageService) { m.On("SendMessage", mock.Anything, mock.Anything).Return(nil, errors.New("failed")) }, wantStatus: 500, wantSuccess: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := echo.New() mockSvc := new(MockMessageService) tt.mockSetup(mockSvc) h := NewMessageHandler(mockSvc, zap.NewNop()) body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/api/v1/messages/send", bytes.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() c := e.NewContext(req, rec) err := h.SendMessage(c) if err != nil { e.HTTPErrorHandler(err, c) } assert.Equal(t, tt.wantStatus, rec.Code) var resp map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &resp) assert.Equal(t, tt.wantSuccess, resp["success"]) mockSvc.AssertExpectations(t) }) } } ``` ## Environment Variables | Variable | Description | Required | Default | |----------|-------------|----------|---------| | `SENT_DM_API_KEY` | Sent DM API key | Yes | - | | `SENT_DM_WEBHOOK_SECRET` | Webhook signature secret | Yes | - | | `PORT` | Server port | No | `8080` | | `SERVER_READ_TIMEOUT` | Request read timeout | No | `5s` | | `SERVER_WRITE_TIMEOUT` | Response write timeout | No | `10s` | | `ENVIRONMENT` | Environment name | No | `development` | | `LOG_LEVEL` | Log level | No | `info` | | `LOG_FORMAT` | Log format (json/console) | No | `json` | | `RATE_LIMIT_RPS` | Rate limit per second | No | `10` | ### Example .env ```bash PORT=8080 SERVER_READ_TIMEOUT=5s SERVER_WRITE_TIMEOUT=10s ENVIRONMENT=development SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here LOG_LEVEL=info LOG_FORMAT=json RATE_LIMIT_RPS=10 ``` ## Docker ```dockerfile # Dockerfile FROM golang:1.22-alpine AS builder WORKDIR /app RUN apk add --no-cache git COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -o main ./cmd/api FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /app/main . EXPOSE 8080 CMD ["./main"] ``` ```yaml # docker-compose.yml version: '3.8' services: api: build: . ports: - "8080:8080" environment: - PORT=8080 - ENVIRONMENT=production - SENT_DM_API_KEY=${SENT_DM_API_KEY} - SENT_DM_WEBHOOK_SECRET=${SENT_DM_WEBHOOK_SECRET} - LOG_LEVEL=info healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 ``` ## go.mod ``` module github.com/sentdm/sent-dm-go/sent-echo-app go 1.22 require ( github.com/go-playground/validator/v10 v10.18.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/labstack/echo/v4 v4.11.4 github.com/sentdm/sent-dm-go v0.7.0 github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 ) ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Go SDK reference](/sdks/go) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/go/integrations/gin.txt TITLE: Gin Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/go/integrations/gin.txt Production-ready Gin integration with Go 1.22+ best practices # Gin Integration Complete production-ready Gin integration with Go 1.22+ patterns, structured logging, graceful shutdown, and modular architecture. This example uses `github.com/sentdm/sent-dm-go` with modern Go patterns including service layers, middleware chains, and proper error handling. ## Project Structure ``` sent-gin-service/ ├── cmd/api/main.go # Entry point ├── internal/ │ ├── config/config.go # Viper configuration │ ├── handlers/ │ │ ├── message_handler.go # HTTP handlers │ │ └── webhook_handler.go # Webhook handlers │ ├── middleware/ # Middleware chain │ ├── service/ # Business logic │ └── models/ # Request/response models ├── pkg/sentdm/client.go # Sent client wrapper ├── go.mod ├── .env.example ├── Dockerfile └── Makefile ``` ## Setup ```bash go mod init sent-gin-service go get github.com/gin-gonic/gin github.com/sentdm/sent-dm-go github.com/spf13/viper go get github.com/swaggo/swag github.com/swaggo/gin-swagger golang.org/x/time/rate ``` ## Configuration ```go // internal/config/config.go package config import ( "fmt" "time" "github.com/spf13/viper" ) type Config struct { Server ServerConfig `mapstructure:"server"` Sent SentConfig `mapstructure:"sent"` Log LogConfig `mapstructure:"log"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` } type ServerConfig struct { Port string `mapstructure:"port"` Host string `mapstructure:"host"` ReadTimeout time.Duration `mapstructure:"read_timeout"` WriteTimeout time.Duration `mapstructure:"write_timeout"` ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` } type SentConfig struct { APIKey string `mapstructure:"api_key"` WebhookSecret string `mapstructure:"webhook_secret"` } type LogConfig struct { Level string `mapstructure:"level"` Format string `mapstructure:"format"` } type RateLimitConfig struct { RequestsPerSecond float64 `mapstructure:"requests_per_second"` BurstSize int `mapstructure:"burst_size"` TTL time.Duration `mapstructure:"ttl"` } func Load() (*Config, error) { viper.SetDefault("server.port", "8080") viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.read_timeout", "30s") viper.SetDefault("server.write_timeout", "30s") viper.SetDefault("server.shutdown_timeout", "30s") viper.SetDefault("log.level", "info") viper.SetDefault("log.format", "json") viper.SetDefault("rate_limit.requests_per_second", 10) viper.SetDefault("rate_limit.burst_size", 20) viper.SetEnvPrefix("") viper.AutomaticEnv() viper.SetConfigFile(".env") _ = viper.ReadInConfig() var cfg Config if err := viper.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } return &cfg, nil } func (c *Config) Addr() string { return fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port) } ``` ### Environment Variables ```bash # .env.example SERVER_PORT=8080 SERVER_HOST=0.0.0.0 SERVER_READ_TIMEOUT=30s SERVER_WRITE_TIMEOUT=30s SENT_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here LOG_LEVEL=info LOG_FORMAT=json RATE_LIMIT_REQUESTS_PER_SECOND=10 RATE_LIMIT_BURST_SIZE=20 ``` ## Models ```go // internal/models/message.go package models import ( "time" "github.com/sentdm/sent-dm-go" ) type SendMessageRequest struct { To []string `json:"to" binding:"required,min=1"` TemplateID string `json:"template_id" binding:"required,uuid"` TemplateName string `json:"template_name" binding:"required,min=1,max=100"` Parameters map[string]string `json:"parameters,omitempty"` Channels []string `json:"channels,omitempty" binding:"dive,oneof=sms whatsapp email"` TestMode bool `json:"test_mode"` } type SendMessageResponse struct { MessageID string `json:"message_id"` Status string `json:"status"` Channel string `json:"channel,omitempty"` SentAt time.Time `json:"sent_at"` TestMode bool `json:"test_mode"` } type BulkMessageRequest struct { Recipients []RecipientRequest `json:"recipients" binding:"required,min=1,max=100"` TemplateID string `json:"template_id" binding:"required,uuid"` } type RecipientRequest struct { PhoneNumber string `json:"phone_number" binding:"required"` Parameters map[string]string `json:"parameters,omitempty"` } type ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` StatusCode int `json:"status_code"` Details map[string]string `json:"details,omitempty"` RequestID string `json:"request_id,omitempty"` } func (r *SendMessageRequest) ToSentParams() sentdm.MessageSendParams { params := sentdm.MessageSendParams{ To: r.To, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String(r.TemplateID), Name: sentdm.String(r.TemplateName), Parameters: r.Parameters, }, TestMode: sentdm.Bool(r.TestMode), } if len(r.Channels) > 0 { params.Channels = sentdm.StringSlice(r.Channels) } return params } ``` ```go // internal/models/webhook.go package models import "time" type WebhookEvent struct { Type string `json:"type" binding:"required"` ID string `json:"id" binding:"required,uuid"` Timestamp time.Time `json:"timestamp"` Data WebhookEventData `json:"data"` } type WebhookEventData struct { MessageID string `json:"message_id,omitempty"` Status string `json:"status,omitempty"` Channel string `json:"channel,omitempty"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` } type WebhookResponse struct { Received bool `json:"received"` EventID string `json:"event_id,omitempty"` } ``` ## Client Wrapper ```go // pkg/sentdm/client.go package sentdm import ( "context" "fmt" "log/slog" "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) type Client struct { *sentdm.Client logger *slog.Logger } func NewClient(apiKey string, logger *slog.Logger) (*Client, error) { if apiKey == "" { return nil, fmt.Errorf("API key is required") } client := sentdm.NewClient(option.WithAPIKey(apiKey)) return &Client{Client: client, logger: logger}, nil } func (c *Client) SendMessage(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) { c.logger.Debug("sending message", slog.Any("to", params.To), slog.String("template_id", params.Template.ID.Value)) response, err := c.Messages.Send(ctx, params) if err != nil { c.logger.Error("failed to send message", slog.String("error", err.Error())) return nil, fmt.Errorf("send message: %w", err) } c.logger.Info("message sent", slog.String("message_id", response.Data.Messages[0].ID)) return response, nil } ``` ## Service Layer ```go // internal/service/message_service.go package service import ( "context" "fmt" "log/slog" "time" "sent-gin-service/internal/models" "sent-gin-service/pkg/sentdm" ) type MessageService struct { client *sentdm.Client logger *slog.Logger } func NewMessageService(client *sentdm.Client, logger *slog.Logger) *MessageService { return &MessageService{client: client, logger: logger} } func (s *MessageService) SendMessage(ctx context.Context, req *models.SendMessageRequest) (*models.SendMessageResponse, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() response, err := s.client.SendMessage(ctx, req.ToSentParams()) if err != nil { return nil, fmt.Errorf("send message: %w", err) } message := response.Data.Messages[0] return &models.SendMessageResponse{ MessageID: message.ID, Status: message.Status, Channel: message.Channel, SentAt: time.Now(), TestMode: req.TestMode, }, nil } func (s *MessageService) SendBulkMessages(ctx context.Context, req *models.BulkMessageRequest) ([]models.SendMessageResponse, []error) { var results []models.SendMessageResponse var errs []error for _, recipient := range req.Recipients { singleReq := &models.SendMessageRequest{ To: []string{recipient.PhoneNumber}, TemplateID: req.TemplateID, Parameters: recipient.Parameters, } resp, err := s.SendMessage(ctx, singleReq) if err != nil { errs = append(errs, fmt.Errorf("failed to send to %s: %w", recipient.PhoneNumber, err)) continue } results = append(results, *resp) } return results, errs } ``` ```go // internal/service/webhook_service.go package service import ( "context" "encoding/json" "fmt" "log/slog" "time" "sent-gin-service/internal/models" ) type WebhookService struct { logger *slog.Logger } func NewWebhookService(logger *slog.Logger) *WebhookService { return &WebhookService{logger: logger} } func (s *WebhookService) ProcessEvent(ctx context.Context, event *models.WebhookEvent) { go s.handleEvent(event) } func (s *WebhookService) handleEvent(event *models.WebhookEvent) { logger := s.logger.With(slog.String("event_id", event.ID), slog.String("event_type", event.Type)) logger.Info("processing webhook event") switch event.Type { case "message.delivered": logger.Info("message delivered", slog.String("message_id", event.Data.MessageID)) case "message.failed": if event.Data.Error != nil { logger.Error("message failed", slog.String("error_code", event.Data.Error.Code)) } default: logger.Warn("unhandled webhook event type") } } func (s *WebhookService) ParseEvent(body []byte) (*models.WebhookEvent, error) { var event models.WebhookEvent if err := json.Unmarshal(body, &event); err != nil { return nil, fmt.Errorf("unmarshal event: %w", err) } if event.Timestamp.IsZero() { event.Timestamp = time.Now() } return &event, nil } ``` ## Middleware ```go // internal/middleware/middleware.go package middleware import ( "errors" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/google/uuid" "golang.org/x/time/rate" "sent-gin-service/internal/models" ) // RequestLogger returns structured logging middleware func RequestLogger(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() requestID := c.GetHeader("X-Request-ID") if requestID == "" { requestID = uuid.New().String() } c.Set("requestID", requestID) c.Header("X-Request-ID", requestID) logger := logger.With( slog.String("request_id", requestID), slog.String("method", c.Request.Method), slog.String("path", c.Request.URL.Path), ) c.Set("logger", logger) c.Next() logger.Info("request completed", slog.Int("status", c.Writer.Status()), slog.Duration("duration", time.Since(start)), ) } } // Recovery returns panic recovery middleware func Recovery(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { logger.Error("panic recovered", slog.String("error", fmt.Sprintf("%v", err))) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": "Internal Server Error", }) } }() c.Next() } } // CORS returns CORS middleware func CORS() gin.HandlerFunc { return func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() } } // APIKeyAuth returns API key authentication middleware func APIKeyAuth(validKey string) gin.HandlerFunc { return func(c *gin.Context) { key := c.GetHeader("Authorization") if strings.HasPrefix(key, "Bearer ") { key = strings.TrimPrefix(key, "Bearer ") } if key == "" || (validKey != "" && key != validKey) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } c.Next() } } // RateLimiter implements per-IP rate limiting type RateLimiter struct { limiters map[string]*rate.Limiter mu sync.RWMutex rate rate.Limit burst int } func NewRateLimiter(r rate.Limit, burst int, ttl time.Duration) *RateLimiter { rl := &RateLimiter{limiters: make(map[string]*rate.Limiter), rate: r, burst: burst} go rl.cleanup(ttl) return rl } func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter { rl.mu.Lock() defer rl.mu.Unlock() if limiter, exists := rl.limiters[ip]; exists { return limiter } limiter := rate.NewLimiter(rl.rate, rl.burst) rl.limiters[ip] = limiter return limiter } func (rl *RateLimiter) cleanup(ttl time.Duration) { ticker := time.NewTicker(ttl) defer ticker.Stop() for range ticker.C { rl.mu.Lock() rl.limiters = make(map[string]*rate.Limiter) rl.mu.Unlock() } } func (rl *RateLimiter) RateLimit() gin.HandlerFunc { return func(c *gin.Context) { if !rl.getLimiter(c.ClientIP()).Allow() { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"}) return } c.Next() } } // ErrorHandler returns centralized error handling middleware func ErrorHandler(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { c.Next() if len(c.Errors) == 0 { return } err := c.Errors.Last() requestID, _ := c.Get("requestID") response := models.ErrorResponse{ RequestID: requestID.(string), StatusCode: http.StatusInternalServerError, Error: "Internal Server Error", Message: err.Err.Error(), } var validationErrors validator.ValidationErrors if errors.As(err.Err, &validationErrors) { response.StatusCode = http.StatusBadRequest response.Error = "Validation Error" response.Details = formatValidationErrors(validationErrors) } else if c.Writer.Status() >= 400 { response.StatusCode = c.Writer.Status() response.Error = http.StatusText(c.Writer.Status()) } logger.Error("request error", slog.String("error", err.Err.Error()), slog.Int("status", response.StatusCode)) c.JSON(response.StatusCode, response) } } func formatValidationErrors(errs validator.ValidationErrors) map[string]string { details := make(map[string]string) for _, err := range errs { switch err.Tag() { case "required": details[err.Field()] = "This field is required" case "uuid": details[err.Field()] = "Invalid UUID format" default: details[err.Field()] = "Invalid value" } } return details } ``` ## Handlers ```go // internal/handlers/message_handler.go package handlers import ( "net/http" "github.com/gin-gonic/gin" "sent-gin-service/internal/models" "sent-gin-service/internal/service" ) type MessageHandler struct { service *service.MessageService } func NewMessageHandler(service *service.MessageService) *MessageHandler { return &MessageHandler{service: service} } func (h *MessageHandler) RegisterRoutes(router *gin.RouterGroup) { messages := router.Group("/messages") { messages.POST("/send", h.SendMessage) messages.POST("/bulk", h.SendBulk) } } // SendMessage godoc // @Summary Send a message // @Tags messages // @Accept json // @Produce json // @Param request body models.SendMessageRequest true "Message request" // @Success 200 {object} models.SendMessageResponse // @Router /api/v1/messages/send [post] func (h *MessageHandler) SendMessage(c *gin.Context) { var req models.SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { _ = c.Error(err) return } response, err := h.service.SendMessage(c.Request.Context(), &req) if err != nil { _ = c.Error(err) c.Status(http.StatusInternalServerError) return } c.JSON(http.StatusOK, response) } // SendBulk godoc // @Summary Send bulk messages // @Tags messages // @Accept json // @Produce json // @Param request body models.BulkMessageRequest true "Bulk message request" // @Success 200 {object} object{results=[]models.SendMessageResponse} // @Router /api/v1/messages/bulk [post] func (h *MessageHandler) SendBulk(c *gin.Context) { var req models.BulkMessageRequest if err := c.ShouldBindJSON(&req); err != nil { _ = c.Error(err) return } results, errs := h.service.SendBulkMessages(c.Request.Context(), &req) c.JSON(http.StatusOK, gin.H{"results": results, "errors": errs, "count": len(results)}) } ``` ```go // internal/handlers/webhook_handler.go package handlers import ( "io" "net/http" "os" "github.com/gin-gonic/gin" "sent-gin-service/internal/models" "sent-gin-service/internal/service" ) type WebhookHandler struct { service *service.WebhookService webhookSecret string } func NewWebhookHandler(service *service.WebhookService) *WebhookHandler { return &WebhookHandler{service: service, webhookSecret: os.Getenv("SENT_DM_WEBHOOK_SECRET")} } func (h *WebhookHandler) RegisterRoutes(router *gin.Engine) { router.POST("/webhooks/sent", h.HandleWebhook) } // HandleWebhook godoc // @Summary Handle Sent webhooks // @Tags webhooks // @Accept json // @Produce json // @Success 200 {object} models.WebhookResponse // @Router /webhooks/sent [post] func (h *WebhookHandler) HandleWebhook(c *gin.Context) { signature := c.GetHeader("X-Webhook-Signature") if signature == "" { c.JSON(http.StatusUnauthorized, models.ErrorResponse{Error: "Unauthorized", Message: "Missing signature"}) return } body, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Bad Request", Message: "Failed to read body"}) return } event, err := h.service.ParseEvent(body) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Bad Request", Message: "Invalid JSON"}) return } h.service.ProcessEvent(c.Request.Context(), event) c.JSON(http.StatusOK, models.WebhookResponse{Received: true, EventID: event.ID}) } ``` ## Main Application ```go // cmd/api/main.go package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "sent-gin-service/internal/config" "sent-gin-service/internal/handlers" "sent-gin-service/internal/middleware" "sent-gin-service/internal/service" "sent-gin-service/pkg/sentdm" _ "sent-gin-service/start/swagger" ) // @title Sent DM Gin Service API // @version 1.0 // @description Production-ready Sent DM integration with Gin // @host localhost:8080 // @BasePath /api/v1 // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization func main() { cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) os.Exit(1) } logger := setupLogger(cfg.Log) logger.Info("starting server", slog.String("addr", cfg.Addr())) if cfg.Log.Level == "debug" { gin.SetMode(gin.DebugMode) } else { gin.SetMode(gin.ReleaseMode) } sentClient, err := sentdm.NewClient(cfg.Sent.APIKey, logger) if err != nil { logger.Error("failed to create sent client", slog.String("error", err.Error())) os.Exit(1) } messageService := service.NewMessageService(sentClient, logger) webhookService := service.NewWebhookService(logger) messageHandler := handlers.NewMessageHandler(messageService) webhookHandler := handlers.NewWebhookHandler(webhookService) rateLimiter := middleware.NewRateLimiter(cfg.RateLimit.RequestsPerSecond, cfg.RateLimit.BurstSize, cfg.RateLimit.TTL) router := gin.New() router.Use(middleware.Recovery(logger)) router.Use(middleware.RequestLogger(logger)) router.Use(middleware.CORS()) router.Use(middleware.ErrorHandler(logger)) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "healthy", "timestamp": time.Now().UTC()}) }) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) webhookHandler.RegisterRoutes(router) apiV1 := router.Group("/api/v1") apiV1.Use(middleware.APIKeyAuth(cfg.Sent.APIKey)) apiV1.Use(rateLimiter.RateLimit()) messageHandler.RegisterRoutes(apiV1) srv := &http.Server{ Addr: cfg.Addr(), Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, } go func() { logger.Info("server listening", slog.String("addr", srv.Addr)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("server failed to start", slog.String("error", err.Error())) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit logger.Info("shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) defer cancel() if err := srv.Shutdown(ctx); err != nil { logger.Error("server forced to shutdown", slog.String("error", err.Error())) os.Exit(1) } logger.Info("server exited gracefully") } func setupLogger(cfg config.LogConfig) *slog.Logger { var level slog.Level switch cfg.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } opts := &slog.HandlerOptions{Level: level} var handler slog.Handler if cfg.Format == "json" { handler = slog.NewJSONHandler(os.Stdout, opts) } else { handler = slog.NewTextHandler(os.Stdout, opts) } return slog.New(handler) } ``` ## Testing ```go // internal/handlers/message_handler_test.go package handlers import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "sent-gin-service/internal/models" ) type MockMessageService struct { mock.Mock } func (m *MockMessageService) SendMessage(ctx context.Context, req *models.SendMessageRequest) (*models.SendMessageResponse, error) { args := m.Called(ctx, req) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.SendMessageResponse), args.Error(1) } func (m *MockMessageService) SendBulkMessages(ctx context.Context, req *models.BulkMessageRequest) ([]models.SendMessageResponse, []error) { args := m.Called(ctx, req) return args.Get(0).([]models.SendMessageResponse), args.Get(1).([]error) } func TestMessageHandler_SendMessage(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string request interface{} setupMock func(*MockMessageService) expectedStatus int }{ { name: "successful message send", request: models.SendMessageRequest{ To: []string{"+1234567890"}, TemplateID: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", TemplateName: "welcome", }, setupMock: func(m *MockMessageService) { m.On("SendMessage", mock.Anything, mock.Anything).Return(&models.SendMessageResponse{MessageID: "msg_123", Status: "sent"}, nil) }, expectedStatus: http.StatusOK, }, { name: "missing required fields", request: map[string]string{}, setupMock: func(m *MockMessageService) {}, expectedStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockService := new(MockMessageService) tt.setupMock(mockService) handler := NewMessageHandler(mockService) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) body, _ := json.Marshal(tt.request) c.Request = httptest.NewRequest(http.MethodPost, "/messages/send", bytes.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") handler.SendMessage(c) assert.Equal(t, tt.expectedStatus, w.Code) mockService.AssertExpectations(t) }) } } ``` ## Makefile ```makefile .PHONY: build run test swagger docker-build docker-run APP_NAME := sent-gin-service PORT := 8080 build: go build -o bin/$(APP_NAME) cmd/api/main.go run: go run cmd/api/main.go test: go test -v -race -cover ./... swagger: swag init -g cmd/api/main.go -o docs/swagger docker-build: docker build -t $(APP_NAME):latest . docker-run: docker run -p $(PORT):8080 --env-file .env $(APP_NAME):latest ``` ## Dockerfile ```dockerfile # Build stage FROM golang:1.23-alpine AS builder WORKDIR /app RUN apk add --no-cache git COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/api/main.go # Final stage FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /app/main . COPY --from=builder /app/docs ./docs EXPOSE 8080 CMD ["./main"] ``` ## Environment Variables Reference ```bash # Server Configuration SERVER_PORT=8080 SERVER_HOST=0.0.0.0 SERVER_READ_TIMEOUT=30s SERVER_WRITE_TIMEOUT=30s SERVER_SHUTDOWN_TIMEOUT=30s # Sent DM Configuration SENT_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here # Logging LOG_LEVEL=info # debug, info, warn, error LOG_FORMAT=json # json, text # Rate Limiting RATE_LIMIT_REQUESTS_PER_SECOND=10 RATE_LIMIT_BURST_SIZE=20 RATE_LIMIT_TTL=1h ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Go SDK reference](/sdks/go) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks.txt TITLE: Sent SDKs ================================================================================ URL: https://docs.sent.dm/llms/sdks.txt # Sent SDKs Official SDKs for Sent's unified messaging API. Send SMS and WhatsApp messages using your favorite programming language. Code2, Braces, Coffee, Hash, Terminal, Globe, Gem, Layers, Server, Flame, Box, Leaf, Zap, Shield, Clock, Diamond, Code } from "lucide-react"; # Sent SDKs Build messaging into your application in minutes. The official Sent LogoSent SDKs provide idiomatic, type-safe clients for every major language — with automatic retries, webhook signature verification, and intelligent error handling built in. **New to Sent?** Start with the [Quickstart Guide](/start/quickstart) to set up your account and send your first message. ## Prerequisites Before using any SDK, you'll need: 1. **A Sent account** - Sign up at [app.sent.dm](https://app.sent.dm) 2. **An API key** - Get yours from the [Sent Dashboard](https://app.sent.dm/dashboard/api-keys) 3. **A template** - Create a message template in the dashboard (WhatsApp templates require approval) **Environment Variable:** All SDKs use `SENT_DM_API_KEY` for authentication. Set this in your environment before running your application. ## Why Use the SDKs? } description="Full type definitions for TypeScript, Python, Go, Java, C#, PHP, and Ruby. Catch errors at compile time." /> } description="Automatic exponential backoff for rate limits and transient failures. Configurable retry policies." /> } description="Built-in signature verification to ensure webhook events are authentic and untampered." /> } description="Most SDKs have zero external dependencies. Just install and start sending messages." /> ## Official SDKs } /> } /> } /> } /> } /> } /> } /> ## Quick Comparison See how simple it is to send a message in each language: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // Uses SENT_DM_API_KEY env var const response = await client.messages.send({ to: ['+1234567890'], template: { id: 'your-template-id', name: 'welcome' }, // testMode: true, // Uncomment to test without sending }); console.log(`Sent: ${response.data.messages[0].id}`); ``` ```python from sent_dm import SentDm client = SentDm() # Uses SENT_DM_API_KEY env var response = client.messages.send( to=["+1234567890"], template={ "id": "your-template-id", "name": "welcome" }, # test_mode=True, # Uncomment to test without sending ) print(f"Sent: {response.data.messages[0].id}") ``` ```go import ( "github.com/sentdm/sent-dm-go" "github.com/sentdm/sent-dm-go/option" ) client := sentdm.NewClient() // Uses SENT_DM_API_KEY env var response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("your-template-id"), Name: sentdm.String("welcome"), }, // TestMode: sentdm.Bool(true), // Uncomment to test without sending }) ``` ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import dm.sent.models.messages.MessageSendParams; SentDmClient client = SentDmOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("your-template-id") .build()) // .testMode(true) // Uncomment to test without sending .build(); var response = client.messages().send(params); System.out.println("Sent: " + response.data().messages().get(0).id()); ``` ```csharp using Sentdm; SentDmClient client = new(); // Uses SENT_DM_API_KEY env var var response = await client.Messages.SendAsync(new MessageSendParams { To = new List { "+1234567890" }, Template = new MessageSendParamsTemplate { ID = "your-template-id" }, // TestMode = true, // Uncomment to test without sending }); Console.WriteLine($"Sent: {response.Data.Messages[0].Id}"); ``` ```php use SentDM\Client; $client = new Client($_ENV['SENT_DM_API_KEY']); $response = $client->messages->send( to: ['+1234567890'], template: [ 'id' => 'your-template-id', 'name' => 'welcome' ], // testMode: true, // Uncomment to test without sending ); echo "Sent: {$response->data->messages[0]->id}\n"; ``` ```ruby require "sentdm" sent_dm = Sentdm::Client.new # Uses SENT_DM_API_KEY env var response = sent_dm.messages.send( to: ['+1234567890'], template: { id: 'your-template-id', name: 'welcome' }, # test_mode: true, # Uncomment to test without sending ) puts "Sent: #{response.data.messages[0].id}" ``` **Note:** Each SDK follows its language's conventions. Method names, parameter styles, and error handling vary by language. See the individual SDK pages for detailed documentation. **Testing:** Use `test_mode: true` (or `test_mode=True` in Python) in development to validate requests without sending real messages. The API will validate your request but not actually send any messages. ## SDK Versions | Language | Package | Current Version | Changelog | |----------|---------|-----------------|-----------| | **TypeScript** | `@sentdm/sentdm` | 0.20.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-typescript/releases) | | **Python** | `sentdm` | 0.18.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-python/releases) | | **Go** | `github.com/sentdm/sent-dm-go` | 0.17.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-go/releases) | | **Java** | `dm.sent:sent-dm-java` | 0.15.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-java/releases) | | **C#** | `Sentdm` | 0.17.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-csharp/releases) | | **PHP** | `sentdm/sent-dm-php` | 0.15.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-php/releases) | | **Ruby** | `sentdm` | 0.13.0 | [GitHub Releases](https://github.com/sentdm/sent-dm-ruby/releases) | ## Framework Quickstarts Get up and running quickly with popular frameworks: } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ## Installation All SDKs are available through standard package managers: ```bash npm install @sentdm/sentdm ``` ```bash pip install sentdm ``` ```bash go get github.com/sentdm/sent-dm-go ``` ```xml dm.sent sent-dm-java 0.15.0 ``` ```bash dotnet add package Sentdm ``` ```bash composer require sentdm/sent-dm-php ``` ```bash gem install sentdm ``` ## Open Source All Sent SDKs are open source and available on GitHub: - [TypeScript SDK](https://github.com/sentdm/sent-dm-typescript) - [Python SDK](https://github.com/sentdm/sent-dm-python) - [Go SDK](https://github.com/sentdm/sent-dm-go) - [Java SDK](https://github.com/sentdm/sent-dm-java) - [C# SDK](https://github.com/sentdm/sent-dm-csharp) - [PHP SDK](https://github.com/sentdm/sent-dm-php) - [Ruby SDK](https://github.com/sentdm/sent-dm-ruby) **Contributions welcome!** Found a bug or want to add a feature? We accept pull requests on all SDK repositories. ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/java.txt TITLE: Java SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/java.txt Official Java SDK for Sent. Enterprise-grade client with Spring Boot integration and full async support. # Java SDK The official Java SDK for Sent LogoSent provides a robust, enterprise-ready client with full support for synchronous and asynchronous operations. Built for Spring Boot, Jakarta EE, and standalone applications with builder patterns throughout. ## Requirements This library requires Java 8 or later. ## Installation ```xml dm.sent sent-dm-java 0.15.0 ``` ```kotlin implementation("dm.sent:sent-dm-java:0.15.0") ``` ## Quick Start ### Initialize the client ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; // Configures using the `sentdm.apiKey` system property // Or configures using the `SENT_DM_API_KEY` environment variable SentDmClient client = SentDmOkHttpClient.fromEnv(); ``` ### Send your first message ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; SentDmClient client = SentDmOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addChannel("sms") .addChannel("whatsapp") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("order_confirmation") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("name", JsonValue.from("John Doe")) .putAdditionalProperty("order_id", JsonValue.from("12345")) .build()) .build()) .build(); var response = client.messages().send(params); System.out.println("Sent: " + response.data().messages().get(0).id()); System.out.println("Status: " + response.data().messages().get(0).status()); ``` ## Client configuration Configure the client using system properties or environment variables: | Setter | System property | Environment variable | Required | Default value | |--------|-----------------|----------------------|----------|---------------| | `apiKey` | `sentdm.apiKey` | `SENT_DM_API_KEY` | true | - | | `baseUrl` | `sentdm.baseUrl` | `SENT_DM_BASE_URL` | true | `"https://api.sent.dm"` | System properties take precedence over environment variables. ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; // From environment variables SentDmClient client = SentDmOkHttpClient.fromEnv(); // Or manually configure SentDmClient client = SentDmOkHttpClient.builder() .apiKey("your_api_key") .build(); // Or combine both approaches SentDmClient client = SentDmOkHttpClient.builder() .fromEnv() .apiKey("overridden_api_key") .build(); ``` Don't create more than one client in the same application. Each client has a connection pool and thread pools, which are more efficient to share between requests. ## Send Messages ### Send a message ```java import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .addChannel("sms") .addChannel("whatsapp") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("order_confirmation") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("name", JsonValue.from("John Doe")) .putAdditionalProperty("order_id", JsonValue.from("12345")) .build()) .build()) .build(); MessageSendResponse response = client.messages().send(params); System.out.println("Message ID: " + response.data().messages().get(0).id()); System.out.println("Status: " + response.data().messages().get(0).status()); ``` ### Test mode Use `testMode(true)` to validate requests without sending real messages: ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .name("order_confirmation") .build()) .testMode(true) // Validates but doesn't send .build(); MessageSendResponse response = client.messages().send(params); // Response will have test data System.out.println("Validation passed: " + response.data().messages().get(0).id()); ``` ## Asynchronous execution The default client is synchronous. To switch to asynchronous execution, call the `async()` method: ```java import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import java.util.concurrent.CompletableFuture; SentDmClient client = SentDmOkHttpClient.fromEnv(); MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("7ba7b820-9dad-11d1-80b4-00c04fd430c8") .build()) .build(); CompletableFuture future = client.async().messages().send(params); // Handle result future.thenAccept(response -> { System.out.println("Sent: " + response.data().messages().get(0).id()); }).exceptionally(throwable -> { System.err.println("Failed: " + throwable.getMessage()); return null; }); ``` Or create an asynchronous client from the beginning: ```java import dm.sent.client.SentDmClientAsync; import dm.sent.client.okhttp.SentDmOkHttpClientAsync; SentDmClientAsync client = SentDmOkHttpClientAsync.fromEnv(); CompletableFuture future = client.messages().send(params); ``` ## Error handling When the API returns a non-success status code, a subclass of `SentDmException` will be thrown: | Status | Exception | |--------|-----------| | 400 | `BadRequestException` | | 401 | `AuthenticationException` | | 403 | `PermissionDeniedException` | | 404 | `NotFoundException` | | 422 | `UnprocessableEntityException` | | 429 | `RateLimitException` | | >=500 | `InternalServerException` | ```java try { var response = client.messages().send(params); System.out.println("Sent: " + response.data().messages().get(0).id()); } catch (NotFoundException e) { System.err.println("Contact or template not found: " + e.getMessage()); } catch (RateLimitException e) { System.err.println("Rate limited. Retry after: " + e.retryAfter()); } catch (AuthenticationException e) { System.err.println("Authentication failed - check API key"); } catch (SentDmException e) { System.err.println("Error: " + e.getMessage()); } ``` ## Raw responses To access response headers, status code, or raw body, prefix any HTTP method call with `withRawResponse()`: ```java import dm.sent.core.http.Headers; import dm.sent.core.http.HttpResponse; var response = client.withRawResponse().messages().send(params); var statusCode = response.statusCode(); var headers = response.headers(); // Deserialize if needed var deserialized = response.body().deserialize(); ``` ## Contacts Create and manage contacts: ```java import dm.sent.models.contacts.ContactCreateParams; // Create a contact ContactCreateParams createParams = ContactCreateParams.builder() .phoneNumber("+1234567890") .build(); var contact = client.contacts().create(createParams); System.out.println("Contact ID: " + contact.data().id()); // List contacts import dm.sent.models.contacts.ContactListParams; ContactListParams listParams = ContactListParams.builder() .limit(100) .build(); var contacts = client.contacts().list(listParams); for (var c : contacts.data().data()) { System.out.println(c.phoneNumber() + " - " + c.availableChannels()); } // Get a contact var contact = client.contacts().get("contact-uuid"); // Update a contact import dm.sent.models.contacts.ContactUpdateParams; ContactUpdateParams updateParams = ContactUpdateParams.builder() .phoneNumber("+1987654321") .build(); var updated = client.contacts().update("contact-uuid", updateParams); // Delete a contact client.contacts().delete("contact-uuid"); ``` ## Templates List and retrieve templates: ```java // List templates var templates = client.templates().list(); for (var template : templates.data().data()) { System.out.println(template.name() + " (" + template.status() + "): " + template.id()); } // Get a specific template var template = client.templates().get("template-uuid"); System.out.println("Name: " + template.data().name()); System.out.println("Status: " + template.data().status()); ``` ## Spring Boot Integration ### Configuration ```java // config/SentConfig.java package com.example.config; import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SentConfig { @Bean public SentDmClient sentClient(@Value("${sent.api-key}") String apiKey) { return SentDmOkHttpClient.builder() .apiKey(apiKey) .build(); } } ``` ```yaml # application.yml sent: api-key: ${SENT_DM_API_KEY} ``` ### Service Layer ```java // service/MessageService.java package com.example.service; import dm.sent.client.SentDmClient; import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; import org.springframework.stereotype.Service; @Service public class MessageService { private final SentDmClient sentClient; public MessageService(SentDmClient sentClient) { this.sentClient = sentClient; } public void sendWelcomeMessage(String phoneNumber, String name) { MessageSendParams params = MessageSendParams.builder() .addTo(phoneNumber) .template(MessageSendParams.Template.builder() .id("welcome-template") .name("welcome") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("name", JsonValue.from(name)) .build()) .build()) .build(); var response = sentClient.messages().send(params); System.out.println("Sent: " + response.data().messages().get(0).id()); } } ``` ## Client customization To temporarily use a modified client configuration, while reusing the same connection and thread pools, call `withOptions()`: ```java import dm.sent.client.SentDmClient; SentDmClient clientWithOptions = client.withOptions(optionsBuilder -> { optionsBuilder.baseUrl("https://example.com"); optionsBuilder.maxRetries(5); }); ``` The `withOptions()` method does not affect the original client. ## Immutability Each class in the SDK has an associated builder for constructing it. Each class is immutable once constructed. If the class has an associated builder, then it has a `toBuilder()` method for making a modified copy. ```java MessageSendParams params = MessageSendParams.builder() .addTo("+1234567890") .template(MessageSendParams.Template.builder() .id("template-id") .build()) .build(); // Create a modified copy MessageSendParams modified = params.toBuilder() .addTo("+0987654321") .build(); ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.Base64; import java.util.Map; @RestController public class WebhookController { @PostMapping("/webhooks/sent") public ResponseEntity handleWebhook( @RequestBody byte[] payload, // raw bytes — do NOT use @RequestBody String @RequestHeader("X-Webhook-ID") String webhookId, @RequestHeader("X-Webhook-Timestamp") String timestamp, @RequestHeader("X-Webhook-Signature") String signature ) throws Exception { // 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" String secret = System.getenv("SENT_WEBHOOK_SECRET"); // "whsec_abc123..." String keyBase64 = secret.startsWith("whsec_") ? secret.substring(6) : secret; byte[] keyBytes = Base64.getDecoder().decode(keyBase64); String signed = webhookId + "." + timestamp + "." + new String(payload, StandardCharsets.UTF_8); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); String expected = "v1," + Base64.getEncoder().encodeToString( mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)) ); if (!MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) { return ResponseEntity.status(401).body(Map.of("error", "Invalid signature")); } // 2. Optional: reject replayed events older than 5 minutes if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300) { return ResponseEntity.status(401).body(Map.of("error", "Timestamp too old")); } // 3. Handle events — update message status in your own database Map event = objectMapper.readValue(payload, Map.class); if ("messages".equals(event.get("field"))) { Map p = (Map) event.get("payload"); // messageRepository.updateStatus((String) p.get("message_id"), (String) p.get("message_status")); } // 4. Always return 200 quickly return ResponseEntity.ok(Map.of("received", true)); } } ``` See the [Webhooks reference](/reference/api/webhooks) for the full payload schema and all status values. ## Source & Issues - **Version**: 0.15.0 - **GitHub**: [sentdm/sent-dm-java](https://github.com/sentdm/sent-dm-java) - **Maven Central**: [dm.sent:sent-dm-java](https://central.sonatype.com/artifact/dm.sent/sent-dm-java) - **Javadoc**: [javadoc.io](https://javadoc.io/doc/dm.sent/sent-dm-java/0.15.0) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-java/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/java/integrations/spring-boot.txt TITLE: Spring Boot Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/java/integrations/spring-boot.txt Production-ready Spring Boot 3.x integration with auto-configuration, validation, and monitoring # Spring Boot Integration Production-ready Spring Boot 3.x integration with auto-configuration, validation, async processing, and webhook handling. This guide uses the Sent Java SDK (`dm.sent:sent-dm-java`) with Spring Boot 3.x, Jakarta EE, and modern Java features. ## Project Setup ### Maven Dependencies ```xml dm.sent sent-dm-java 0.6.0 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-data-jpa org.postgresql postgresql runtime org.springframework.boot spring-boot-starter-actuator org.springdoc springdoc-openapi-starter-webmvc-ui 2.3.0 org.springframework.boot spring-boot-starter-test test ``` ## Configuration ### Application Properties ```yaml # application.yml spring: application: name: sent-dm-integration profiles: active: ${SPRING_PROFILES_ACTIVE:dev} datasource: url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/sent_dm} username: ${DATABASE_USERNAME:postgres} password: ${DATABASE_PASSWORD:password} jpa: hibernate: ddl-auto: validate show-sql: false flyway: enabled: true locations: classpath:db/migration sent: api: key: ${SENT_DM_API_KEY} base-url: ${SENT_DM_BASE_URL:https://api.sent.dm} timeout-seconds: 30 webhook: secret: ${SENT_DM_WEBHOOK_SECRET} path: /webhooks/sent async: core-pool-size: 5 max-pool-size: 10 queue-capacity: 100 server: port: 8080 management: endpoints: web: exposure: include: health,info,metrics endpoint: health: probes: enabled: true springdoc: api-docs: path: /api-docs swagger-ui: path: /swagger-ui.html --- # Profile: dev spring: config: activate: on-profile: dev datasource: url: jdbc:h2:mem:sent_dm_dev driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create-drop show-sql: true --- # Profile: prod spring: config: activate: on-profile: prod jpa: show-sql: false logging: level: com.example.sent: WARN ``` ## DTOs ```java // dto/SendMessageRequest.java package com.example.sent.dto; import jakarta.validation.constraints.*; import java.util.List; import java.util.Map; public record SendMessageRequest( @NotEmpty @Size(max = 100) List<@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String> to, @NotBlank @Size(max = 255) String templateId, @NotBlank @Pattern(regexp = "^[a-zA-Z0-9_-]+$") String templateName, Map parameters, List<@Pattern(regexp = "^(whatsapp|sms)$") String> channels ) {} // dto/SendWelcomeRequest.java package com.example.sent.dto; import jakarta.validation.constraints.*; public record SendWelcomeRequest( @NotBlank @Pattern(regexp = "^\\+?[1-9]\\d{1,14}$") String phoneNumber, @Size(max = 100) String name, @Pattern(regexp = "^[a-z]{2}$") String language ) {} // dto/MessageResponse.java package com.example.sent.dto; import java.time.Instant; import java.util.List; public record MessageResponse( String messageId, String status, Instant timestamp, List channels ) {} // dto/webhook/WebhookEvent.java package com.example.sent.dto.webhook; import java.time.Instant; public record WebhookEvent(String type, String id, Instant timestamp, WebhookEventData data) {} public record WebhookEventData(String id, String status, WebhookError error) {} public record WebhookError(String code, String message) {} ``` ## Entity Layer ```java // entity/WebhookEventEntity.java package com.example.sent.entity; import jakarta.persistence.*; import org.hibernate.annotations.CreationTimestamp; import java.time.Instant; @Entity @Table(name = "webhook_events", indexes = { @Index(name = "idx_event_id", columnList = "eventId"), @Index(name = "idx_status", columnList = "status") }) public class WebhookEventEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) private String id; @Column(nullable = false, unique = true) private String eventId; @Column(nullable = false) private String eventType; private String messageId; private String status; @Column(nullable = false, columnDefinition = "TEXT") private String payload; @Enumerated(EnumType.STRING) private ProcessingStatus processingStatus = ProcessingStatus.PENDING; private Integer retryCount = 0; @CreationTimestamp private Instant createdAt; public enum ProcessingStatus { PENDING, PROCESSING, COMPLETED, FAILED } public WebhookEventEntity() {} public WebhookEventEntity(String eventId, String eventType, String payload) { this.eventId = eventId; this.eventType = eventType; this.payload = payload; } // Getters and setters... } // repository/WebhookEventRepository.java package com.example.sent.repository; import com.example.sent.entity.WebhookEventEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface WebhookEventRepository extends JpaRepository { Optional findByEventId(String eventId); boolean existsByEventId(String eventId); } ``` ## Configuration Classes ```java // config/AsyncConfig.java package com.example.sent.config; import org.springframework.context.annotation.*; import org.springframework.scheduling.annotation.*; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration @EnableAsync @EnableScheduling public class AsyncConfig { @Bean(name = "webhookTaskExecutor") public Executor webhookTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("webhook-"); executor.initialize(); return executor; } } // config/SentConfig.java package com.example.sent.config; import dm.sent.client.SentDmClient; import dm.sent.client.okhttp.SentDmOkHttpClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.*; import java.time.Duration; @Configuration public class SentConfig { @Bean public SentDmClient sentClient( @Value("${sent.api.key}") String apiKey, @Value("${sent.api.base-url}") String baseUrl) { return SentDmOkHttpClient.builder() .apiKey(apiKey) .baseUrl(baseUrl) .timeout(Duration.ofSeconds(30)) .build(); } } ``` ## Service Layer ```java // service/MessageService.java package com.example.sent.service; import com.example.sent.dto.*; import java.util.concurrent.CompletableFuture; public interface MessageService { MessageResponse sendMessage(SendMessageRequest request); MessageResponse sendWelcomeMessage(SendWelcomeRequest request); CompletableFuture sendMessageAsync(SendMessageRequest request); } // service/impl/MessageServiceImpl.java package com.example.sent.service.impl; import com.example.sent.dto.*; import com.example.sent.service.MessageService; import dm.sent.client.SentDmClient; import dm.sent.core.JsonValue; import dm.sent.models.messages.MessageSendParams; import dm.sent.models.messages.MessageSendResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.time.Instant; import java.util.List; import java.util.concurrent.CompletableFuture; @Service public class MessageServiceImpl implements MessageService { private static final Logger logger = LoggerFactory.getLogger(MessageServiceImpl.class); private final SentDmClient sentClient; public MessageServiceImpl(SentDmClient sentClient) { this.sentClient = sentClient; } @Override public MessageResponse sendMessage(SendMessageRequest request) { logger.info("Sending message to {} recipients", request.to().size()); MessageSendParams params = buildMessageParams(request); MessageSendResponse response = sentClient.messages().send(params); return new MessageResponse(response.data().messages().get(0).id(), response.data().messages().get(0).status(), Instant.now(), request.channels() != null ? request.channels() : List.of("whatsapp", "sms")); } @Override public MessageResponse sendWelcomeMessage(SendWelcomeRequest request) { String name = request.name() != null ? request.name() : "Customer"; MessageSendParams params = MessageSendParams.builder() .addTo(request.phoneNumber()) .addChannel("whatsapp") .template(MessageSendParams.Template.builder() .name("welcome") .parameters(MessageSendParams.Template.Parameters.builder() .putAdditionalProperty("name", JsonValue.from(name)) .build()) .build()) .build(); MessageSendResponse response = sentClient.messages().send(params); return new MessageResponse(response.data().messages().get(0).id(), response.data().messages().get(0).status(), Instant.now(), List.of("whatsapp")); } @Override @Async("webhookTaskExecutor") public CompletableFuture sendMessageAsync(SendMessageRequest request) { try { return CompletableFuture.completedFuture(sendMessage(request)); } catch (Exception e) { CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(e); return future; } } private MessageSendParams buildMessageParams(SendMessageRequest request) { var paramsBuilder = MessageSendParams.Template.Parameters.builder(); if (request.parameters() != null) { request.parameters().forEach((k, v) -> paramsBuilder.putAdditionalProperty(k, JsonValue.from(v))); } var builder = MessageSendParams.builder() .to(request.to()) .template(MessageSendParams.Template.builder() .id(request.templateId()) .name(request.templateName()) .parameters(paramsBuilder.build()) .build()); if (request.channels() != null) request.channels().forEach(builder::addChannel); return builder.build(); } } // service/WebhookService.java package com.example.sent.service; import com.example.sent.dto.webhook.WebhookEvent; import java.util.concurrent.CompletableFuture; public interface WebhookService { CompletableFuture processWebhookAsync(String payload, String signature); } // service/impl/WebhookServiceImpl.java package com.example.sent.service.impl; import com.example.sent.dto.webhook.WebhookEvent; import com.example.sent.entity.WebhookEventEntity; import com.example.sent.repository.WebhookEventRepository; import com.example.sent.service.WebhookService; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; import java.util.concurrent.CompletableFuture; @Service public class WebhookServiceImpl implements WebhookService { private static final Logger logger = LoggerFactory.getLogger(WebhookServiceImpl.class); private final WebhookEventRepository repository; private final ObjectMapper mapper; private final String webhookSecret; public WebhookServiceImpl(WebhookEventRepository repository, ObjectMapper mapper, @Value("${sent.webhook.secret:}") String webhookSecret) { this.repository = repository; this.mapper = mapper; this.webhookSecret = webhookSecret; } @Override @Async("webhookTaskExecutor") public CompletableFuture processWebhookAsync(String payload, String signature) { try { if (!verifySignature(payload, signature)) { throw new SecurityException("Invalid webhook signature"); } WebhookEvent event = mapper.readValue(payload, WebhookEvent.class); if (repository.existsByEventId(event.id())) { return CompletableFuture.completedFuture(null); } WebhookEventEntity entity = new WebhookEventEntity(event.id(), event.type(), payload); entity.setSignature(signature); if (event.data() != null) { entity.setMessageId(event.data().id()); entity.setStatus(event.data().status()); } repository.save(entity); processEvent(event, entity); return CompletableFuture.completedFuture(null); } catch (Exception e) { logger.error("Failed to process webhook", e); CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(e); return future; } } private void processEvent(WebhookEvent event, WebhookEventEntity entity) { entity.setProcessingStatus(WebhookEventEntity.ProcessingStatus.PROCESSING); repository.save(entity); logger.info("Processing event: {} - type: {}", event.id(), event.type()); // Handle specific event types here... entity.setProcessingStatus(WebhookEventEntity.ProcessingStatus.COMPLETED); entity.setCreatedAt(Instant.now()); // Use as processedAt repository.save(entity); } private boolean verifySignature(String payload, String signature) { if (webhookSecret == null || webhookSecret.isBlank()) return true; if (signature == null) return false; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); String computed = Base64.getEncoder().encodeToString(hash); return signature.equals(computed) || signature.equals("sha256=" + computed); } catch (Exception e) { return false; } } } ``` ## REST Controllers ```java // controller/MessageController.java package com.example.sent.controller; import com.example.sent.dto.*; import com.example.sent.service.MessageService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.concurrent.CompletableFuture; @Tag(name = "Messages") @RestController @RequestMapping("/api/v1/messages") public class MessageController { private final MessageService messageService; public MessageController(MessageService messageService) { this.messageService = messageService; } @PostMapping("/send") public ResponseEntity sendMessage(@Valid @RequestBody SendMessageRequest request) { return ResponseEntity.ok(messageService.sendMessage(request)); } @PostMapping("/welcome") public ResponseEntity sendWelcome(@Valid @RequestBody SendWelcomeRequest request) { return ResponseEntity.ok(messageService.sendWelcomeMessage(request)); } @PostMapping("/send/async") public CompletableFuture> sendAsync(@Valid @RequestBody SendMessageRequest request) { return messageService.sendMessageAsync(request).thenApply(ResponseEntity::ok); } } // controller/WebhookController.java package com.example.sent.controller; import com.example.sent.service.WebhookService; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.concurrent.CompletableFuture; @Tag(name = "Webhooks") @RestController @RequestMapping("/webhooks") public class WebhookController { private final WebhookService webhookService; public WebhookController(WebhookService webhookService) { this.webhookService = webhookService; } @PostMapping("/sent") public CompletableFuture>> handleWebhook( @RequestBody String payload, @RequestHeader(value = "X-Webhook-Signature", required = false) String signature) { return webhookService.processWebhookAsync(payload, signature) .thenApply(v -> ResponseEntity.ok(Map.of("received", true))) .exceptionally(ex -> ResponseEntity.status(500).body(Map.of("error", ex.getMessage()))); } } ``` ## Exception Handling ```java // exception/GlobalExceptionHandler.java package com.example.sent.exception; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import org.springframework.http.*; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; import java.net.URI; import java.time.Instant; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { private static final String ERROR_PREFIX = "https://api.example.com/errors/"; @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); problem.setType(URI.create(ERROR_PREFIX + "validation-error")); problem.setTitle("Validation Failed"); problem.setInstance(URI.create(req.getRequestURI())); Map errors = new HashMap<>(); ex.getBindingResult().getAllErrors().forEach(e -> { String field = e instanceof FieldError ? ((FieldError) e).getField() : e.getObjectName(); errors.put(field, e.getDefaultMessage()); }); problem.setProperty("errors", errors); return ResponseEntity.badRequest().body(problem); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity handleConstraint(ConstraintViolationException ex, HttpServletRequest req) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); problem.setType(URI.create(ERROR_PREFIX + "constraint-violation")); problem.setTitle("Constraint Violation"); problem.setInstance(URI.create(req.getRequestURI())); return ResponseEntity.badRequest().body(problem); } @ExceptionHandler(SecurityException.class) public ResponseEntity handleSecurity(SecurityException ex, HttpServletRequest req) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.FORBIDDEN); problem.setType(URI.create(ERROR_PREFIX + "security-error")); problem.setTitle("Security Error"); problem.setInstance(URI.create(req.getRequestURI())); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problem); } @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex, HttpServletRequest req) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR); problem.setType(URI.create(ERROR_PREFIX + "internal-error")); problem.setTitle("Internal Server Error"); problem.setInstance(URI.create(req.getRequestURI())); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problem); } } ``` ## Testing ```java // controller/MessageControllerTest.java package com.example.sent.controller; import com.example.sent.dto.*; import com.example.sent.service.MessageService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(MessageController.class) class MessageControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper mapper; @MockitoBean private MessageService messageService; @Test void sendWelcome_ShouldReturn200() throws Exception { when(messageService.sendWelcomeMessage(any())) .thenReturn(new MessageResponse("msg_123", "pending", Instant.now(), List.of("whatsapp"))); SendWelcomeRequest req = new SendWelcomeRequest("+1234567890", "John", "en"); mockMvc.perform(post("/api/v1/messages/welcome") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(req))) .andExpect(status().isOk()) .andExpect(jsonPath("$.messageId").value("msg_123")); } @Test void sendWelcome_WithInvalidPhone_ShouldReturn400() throws Exception { SendWelcomeRequest req = new SendWelcomeRequest("invalid", "John", null); mockMvc.perform(post("/api/v1/messages/welcome") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(req))) .andExpect(status().isBadRequest()); } // Additional tests... } // service/impl/MessageServiceImplTest.java package com.example.sent.service.impl; import com.example.sent.dto.*; import dm.sent.client.SentDmClient; import dm.sent.models.messages.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class MessageServiceImplTest { @Mock private SentDmClient sentClient; @Mock private SentDmClient.MessageClient messageClient; @InjectMocks private MessageServiceImpl messageService; @Test void sendMessage_ShouldReturnResponse() { when(sentClient.messages()).thenReturn(messageClient); when(messageClient.send(any())).thenReturn( MessageSendResponse.builder().id("msg_123").status("pending").build()); SendMessageRequest req = new SendMessageRequest( List.of("+1234567890"), "tpl-id", "welcome", null, List.of("whatsapp")); MessageResponse resp = messageService.sendMessage(req); assertThat(resp.messageId()).isEqualTo("msg_123"); } // Additional tests... } ``` ## Docker Compose ```yaml version: '3.8' services: app: build: . ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=dev - SENT_DM_API_KEY=${SENT_DM_API_KEY} - SENT_DM_WEBHOOK_SECRET=${SENT_DM_WEBHOOK_SECRET} - DATABASE_URL=jdbc:postgresql://postgres:5432/sent_dm depends_on: postgres: condition: service_healthy postgres: image: postgres:15-alpine environment: POSTGRES_DB: sent_dm POSTGRES_USER: postgres POSTGRES_PASSWORD: password ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 volumes: postgres_data: ``` ### Dockerfile ```dockerfile FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /app COPY .mvn/ .mvn COPY mvnw pom.xml ./ RUN chmod +x mvnw && ./mvnw dependency:go-offline COPY src ./src RUN ./mvnw clean package -DskipTests FROM eclipse-temurin:21-jre-alpine WORKDIR /app RUN addgroup -S sent && adduser -S sent -G sent COPY --from=builder /app/target/*.jar app.jar RUN chown -R sent:sent /app USER sent EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "-jar", "app.jar"] ``` ## Environment Variables ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret SENT_DM_BASE_URL=https://api.sent.dm DATABASE_URL=jdbc:postgresql://localhost:5432/sent_dm DATABASE_USERNAME=postgres DATABASE_PASSWORD=password SPRING_PROFILES_ACTIVE=dev ``` ## Project Structure ``` src/ ├── main/java/com/example/sent/ │ ├── SentDmApplication.java │ ├── config/ │ │ ├── AsyncConfig.java │ │ └── SentConfig.java │ ├── controller/ │ │ ├── MessageController.java │ │ └── WebhookController.java │ ├── dto/ │ │ ├── SendMessageRequest.java │ │ ├── SendWelcomeRequest.java │ │ ├── MessageResponse.java │ │ └── webhook/WebhookEvent.java │ ├── entity/WebhookEventEntity.java │ ├── exception/GlobalExceptionHandler.java │ ├── repository/WebhookEventRepository.java │ └── service/ │ ├── MessageService.java │ ├── WebhookService.java │ └── impl/ │ ├── MessageServiceImpl.java │ └── WebhookServiceImpl.java ├── main/resources/application.yml └── test/java/com/example/sent/ └── controller/MessageControllerTest.java ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Java SDK reference](/sdks/java) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/php.txt TITLE: PHP SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/php.txt Official PHP SDK for Sent. Elegant syntax with full Laravel and Symfony integration. # PHP SDK The official PHP SDK for Sent LogoSent provides a clean, object-oriented interface for sending messages. Built with modern PHP 8.1+ features. ## Requirements PHP 8.1.0 or higher. ## Installation ```bash composer require sentdm/sent-dm-php ``` To install a specific version: ```bash composer require "sentdm/sent-dm-php 0.15.0" ``` ## Quick Start ### Initialize the client ```php messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome', 'parameters' => [ 'name' => 'John Doe', 'order_id' => '12345' ] ], channel: ['sms', 'whatsapp'] // Optional ); var_dump($result->data->messages[0]->id); var_dump($result->data->messages[0]->status); ``` ## Authentication The client accepts an API key as the first parameter. ```php use SentDM\Client; // Using API key directly $client = new Client('your_api_key'); // Or from environment variable $client = new Client($_ENV['SENT_DM_API_KEY']); ``` ## Send Messages This library uses named parameters to specify optional arguments. Parameters with a default value must be set by name. ### Send a message ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome', 'parameters' => [ 'name' => 'John Doe', 'order_id' => '12345' ] ], channel: ['sms', 'whatsapp'] ); var_dump($result->data->messages[0]->id); var_dump($result->data->messages[0]->status); ``` ### Test mode Use `testMode: true` to validate requests without sending real messages: ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], testMode: true // Validates but doesn't send ); // Response will have test data var_dump($result->data->messages[0]->id); var_dump($result->data->messages[0]->status); ``` ## Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `SentDM\Core\Exceptions\APIException` will be thrown: ```php use SentDM\Core\Exceptions\APIConnectionException; use SentDM\Core\Exceptions\RateLimitException; use SentDM\Core\Exceptions\APIStatusException; try { $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ] ); } catch (APIConnectionException $e) { echo "The server could not be reached", PHP_EOL; var_dump($e->getPrevious()); } catch (RateLimitException $e) { echo "A 429 status code was received; we should back off a bit.", PHP_EOL; } catch (APIStatusException $e) { echo "Another non-200-range status code was received", PHP_EOL; echo $e->getMessage(); } ``` Error codes are as follows: | Cause | Error Type | |-------|------------| | HTTP 400 | `BadRequestException` | | HTTP 401 | `AuthenticationException` | | HTTP 403 | `PermissionDeniedException` | | HTTP 404 | `NotFoundException` | | HTTP 409 | `ConflictException` | | HTTP 422 | `UnprocessableEntityException` | | HTTP 429 | `RateLimitException` | | HTTP >= 500 | `InternalServerException` | | Other HTTP error | `APIStatusException` | | Timeout | `APITimeoutException` | | Network error | `APIConnectionException` | ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. ```php // Configure the default for all requests: $client = new Client($_ENV['SENT_DM_API_KEY'], maxRetries: 0); // Or, configure per-request: $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], requestOptions: ['maxRetries' => 5], ); ``` ## Value Objects It is recommended to use the static `with` constructor and named parameters to initialize value objects. ```php use SentDM\Models\TemplateDefinition; $definition = TemplateDefinition::with( body: [...], header: [...], ); ``` However, builders are also provided: ```php $definition = (new TemplateDefinition)->withBody([...]); ``` ## Contacts Create and manage contacts: ```php // Create a contact $result = $client->contacts->create([ 'phoneNumber' => '+1234567890' ]); var_dump($result->data->id); // List contacts $result = $client->contacts->list(['limit' => 100]); foreach ($result->data->data as $contact) { echo $contact->phoneNumber . " - " . implode(', ', $contact->availableChannels) . "\n"; } // Get a contact $result = $client->contacts->get('contact-uuid'); // Update a contact $result = $client->contacts->update('contact-uuid', [ 'phoneNumber' => '+1987654321' ]); // Delete a contact $client->contacts->delete('contact-uuid'); ``` ## Templates List and retrieve templates: ```php // List templates $result = $client->templates->list(); foreach ($result->data->data as $template) { echo $template->name . " (" . $template->status . "): " . $template->id . "\n"; echo " Category: " . $template->category . "\n"; } // Get a specific template $result = $client->templates->get('template-uuid'); echo "Name: " . $result->data->name . "\n"; echo "Status: " . $result->data->status . "\n"; ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```php // routes/api.php Route::post('/webhooks/sent', [WebhookController::class, 'handleSent']); ``` ```php getContent(); $webhookId = $request->header('X-Webhook-ID', ''); $timestamp = $request->header('X-Webhook-Timestamp', ''); $signature = $request->header('X-Webhook-Signature', ''); // 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" $secret = config('services.sent_dm.webhook_secret'); // "whsec_abc123..." $keyBase64 = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret; $keyBytes = base64_decode($keyBase64); $signed = "{$webhookId}.{$timestamp}.{$payload}"; $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true)); if (!hash_equals($expected, $signature)) { return response()->json(['error' => 'Invalid signature'], 401); } // 3. Optional: reject replayed events older than 5 minutes if (abs(time() - intval($timestamp)) > 300) { return response()->json(['error' => 'Timestamp too old'], 401); } $event = json_decode($payload); // 4. Handle events — update message status in your own database if ($event->field === 'messages') { \DB::table('messages') ->where('sent_id', $event->payload->message_id) ->update(['status' => $event->payload->message_status]); } // 5. Always return 200 quickly return response()->json(['received' => true]); } } ``` See the [Webhooks reference](/reference/api/webhooks) for the full payload schema and all status values. ## Making custom or undocumented requests ### Undocumented properties You can send undocumented parameters to any endpoint using the `extra*` parameters: ```php $result = $client->messages->send( to: ['+1234567890'], template: [ 'id' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name' => 'welcome' ], requestOptions: [ 'extraQueryParams' => ['my_query_parameter' => 'value'], 'extraBodyParams' => ['my_body_parameter' => 'value'], 'extraHeaders' => ['my-header' => 'value'], ], ); ``` ### Undocumented endpoints To make requests to undocumented endpoints while retaining the benefit of auth, retries, and so on: ```php $response = $client->request( method: 'post', path: '/undocumented/endpoint', query: ['dog' => 'woof'], headers: ['useful-header' => 'interesting-value'], body: ['hello' => 'world'] ); ``` ## Laravel Integration ### Service Provider (example) ```php // config/app.php 'providers' => [ // ... App\Providers\SentDMServiceProvider::class, ], // app/Providers/SentDMServiceProvider.php namespace App\Providers; use Illuminate\Support\ServiceProvider; use SentDM\Client; class SentDMServiceProvider extends ServiceProvider { public function register() { $this->app->singleton(Client::class, function ($app) { return new Client( apiKey: config('services.sent_dm.api_key'), ); }); } } ``` ```php // config/services.php return [ // ... 'sent_dm' => [ 'api_key' => env('SENT_DM_API_KEY'), ], ]; ``` ### Dependency Injection ```php client = $client; } public function send(Request $request) { $result = $this->client->messages->send( to: [$request->input('phone_number')], template: [ 'id' => $request->input('template_id'), 'name' => 'welcome', 'parameters' => $request->input('variables', []) ] ); if ($result->success) { return response()->json([ 'message_id' => $result->data->messages[0]->id, 'status' => $result->data->messages[0]->status ]); } return response()->json(['error' => $result->error->message], 400); } } ``` ### Webhook Handler ```php json() first $payload = $request->getContent(); $webhookId = $request->header('X-Webhook-ID', ''); $timestamp = $request->header('X-Webhook-Timestamp', ''); $signature = $request->header('X-Webhook-Signature', ''); // 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" $secret = config('services.sent_dm.webhook_secret'); // "whsec_abc123..." $keyBase64 = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret; $keyBytes = base64_decode($keyBase64); $signed = "{$webhookId}.{$timestamp}.{$payload}"; $expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true)); if (!hash_equals($expected, $signature)) { return response()->json(['error' => 'Invalid signature'], 401); } // 3. Optional: reject replayed events older than 5 minutes if (abs(time() - intval($timestamp)) > 300) { return response()->json(['error' => 'Timestamp too old'], 401); } $event = json_decode($payload); // 4. Update message status in your own database if ($event->field === 'messages') { \DB::table('messages') ->where('sent_id', $event->payload->message_id) ->update(['status' => $event->payload->message_status]); } // 5. Always return 200 quickly return response()->json(['received' => true]); } } ``` ## Symfony Integration ```yaml # config/packages/sent_dm.yaml parameters: env(SENT_DM_API_KEY): '' services: SentDM\Client: arguments: $apiKey: '%env(SENT_DM_API_KEY)%' ``` ```php client = $client; } #[Route('/api/send', methods: ['POST'])] public function send(Request $request): JsonResponse { $data = json_decode($request->getContent(), true); $result = $this->client->messages->send( to: [$data['phone_number']], template: [ 'id' => $data['template_id'], 'name' => 'welcome' ] ); if ($result->success) { return new JsonResponse([ 'message_id' => $result->data->messages[0]->id, 'status' => $result->data->messages[0]->status ]); } return new JsonResponse(['error' => $result->error->message], 400); } } ``` ## Source & Issues - **Version**: 0.15.0 - **GitHub**: [sentdm/sent-dm-php](https://github.com/sentdm/sent-dm-php) - **Packagist**: [sentdm/sent-dm-php](https://packagist.org/packages/sentdm/sent-dm-php) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-php/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/php/integrations/laravel.txt TITLE: Laravel Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/php/integrations/laravel.txt Complete Laravel 11 integration with dependency injection, service layer, queues, webhooks, and testing # Laravel Integration Complete Laravel 11 integration with dependency injection, service layer, queues, webhooks, and comprehensive testing. This guide follows Laravel 11 best practices with PHP 8.2+ features including readonly properties and typed parameters. ## Installation & Setup ### Install Package ```bash composer require sentdm/sent-dm-php ``` ### Environment Configuration ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret SENT_DM_BASE_URL=https://api.sent.dm SENT_DM_TIMEOUT=30 SENT_DM_MAX_RETRIES=3 ``` ### Configuration File ```php env('SENT_DM_API_KEY'), 'webhook_secret' => env('SENT_DM_WEBHOOK_SECRET'), 'base_url' => env('SENT_DM_BASE_URL', 'https://api.sent.dm'), 'timeout' => env('SENT_DM_TIMEOUT', 30), 'max_retries' => env('SENT_DM_MAX_RETRIES', 3), 'webhook' => [ 'queue_connection' => env('SENT_DM_WEBHOOK_QUEUE', 'default'), 'queue_name' => env('SENT_DM_WEBHOOK_QUEUE_NAME', 'webhooks'), 'log_enabled' => env('SENT_DM_WEBHOOK_LOG_ENABLED', true), 'log_retention_days' => env('SENT_DM_WEBHOOK_LOG_RETENTION_DAYS', 30), ], ]; ``` ## Service Provider ```php app->singleton(Client::class, function ($app) { $config = $app['config']['sent-dm']; if (empty($config['api_key'])) { throw new \InvalidArgumentException('Sent DM API key not configured.'); } return new Client( apiKey: $config['api_key'], baseUrl: $config['base_url'] ?? null, timeout: $config['timeout'] ?? 30, maxRetries: $config['max_retries'] ?? 3, ); }); $this->app->singleton(SentDMServiceContract::class, SentDMService::class); $this->app->alias(Client::class, 'sent-dm'); } public function boot(): void { $this->publishes([__DIR__.'/../../config/sent-dm.php' => config_path('sent-dm.php')], 'sent-dm-config'); $this->publishes([__DIR__.'/../../database/migrations' => database_path('migrations')], 'sent-dm-migrations'); } } ``` Register in `bootstrap/providers.php`: ```php client->messages->send( to: [$phoneNumber], template: ['id' => $templateId, 'name' => $templateName, 'parameters' => $parameters], channels: $channels, testMode: $testMode, ); $message = $response->data->messages[0]; Log::info('Message sent', ['message_id' => $message->id, 'phone' => $phoneNumber]); return new MessageResult(success: true, messageId: $message->id, status: $message->status, data: (array) $response->data); } catch (APIException $e) { Log::error('Failed to send message', ['phone' => $phoneNumber, 'error' => $e->getMessage()]); return new MessageResult(success: false, error: $e->getMessage()); } } public function sendWelcomeMessage(string $phoneNumber, ?string $name = null): MessageResult { return $this->sendMessage(phoneNumber: $phoneNumber, templateId: config('sent-dm.templates.welcome.id'), templateName: 'welcome', parameters: ['name' => $name ?? 'Valued Customer'], channels: ['whatsapp']); } public function sendOrderConfirmation(string $phoneNumber, string $orderNumber, string $total): MessageResult { return $this->sendMessage(phoneNumber: $phoneNumber, templateId: config('sent-dm.templates.order_confirmation.id'), templateName: 'order_confirmation', parameters: ['order_number' => $orderNumber, 'total' => $total], channels: ['sms', 'whatsapp']); } public function verifyWebhookSignature(string $payload, string $signature): bool { $secret = config('sent-dm.webhook_secret'); if (empty($secret)) { Log::warning('Webhook secret not configured'); return false; } return hash_equals(hash_hmac('sha256', $payload, $secret), $signature); } } ``` ## Form Requests ```php ['required', 'string', 'regex:/^\+[1-9]\d{1,14}$/'], 'template_id' => ['required', 'string', 'uuid'], 'template_name' => ['required', 'string', 'max:255'], 'parameters' => ['sometimes', 'array'], 'parameters.*' => ['string', 'max:1000'], 'channels' => ['sometimes', 'array'], 'channels.*' => ['string', Rule::in(['sms', 'whatsapp', 'viber', 'telegram'])], 'test_mode' => ['sometimes', 'boolean'], ]; } public function messages(): array { return ['phone_number.regex' => 'Phone number must be in E.164 format (e.g., +1234567890).']; } protected function prepareForValidation(): void { if ($this->has('phone_number')) { $this->merge(['phone_number' => preg_replace('/\s+/', '', $this->phone_number)]); } } } ``` ```php ['required', 'string', 'regex:/^\+[1-9]\d{1,14}$/'], 'name' => ['nullable', 'string', 'max:255'], ]; } } ``` ## Controller ```php validated(); $result = $this->sentService->sendMessage( phoneNumber: $validated['phone_number'], templateId: $validated['template_id'], templateName: $validated['template_name'], parameters: $validated['parameters'] ?? [], channels: $validated['channels'] ?? null, testMode: $validated['test_mode'] ?? false, ); return $result->success ? response()->json(['success' => true, 'data' => ['message_id' => $result->messageId, 'status' => $result->status]]) : response()->json(['success' => false, 'error' => ['message' => $result->error, 'code' => 'SEND_FAILED']], 400); } public function welcome(SendWelcomeRequest $request): JsonResponse { $validated = $request->validated(); $result = $this->sentService->sendWelcomeMessage(phoneNumber: $validated['phone_number'], name: $validated['name'] ?? null); return $result->success ? response()->json(['success' => true, 'data' => ['message_id' => $result->messageId, 'status' => $result->status]]) : response()->json(['success' => false, 'error' => ['message' => $result->error, 'code' => 'WELCOME_SEND_FAILED']], 400); } } ``` ## Routes ```php group(function () { Route::post('/messages/send', [MessageController::class, 'send'])->name('api.messages.send'); Route::post('/messages/welcome', [MessageController::class, 'welcome'])->name('api.messages.welcome'); }); Route::post('/webhooks/sent-dm', [WebhookController::class, 'handle'])->name('webhooks.sent-dm')->middleware('sent-dm.webhook'); ``` Configure rate limiting in `app/Providers/AppServiceProvider.php`: ```php [ Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()), ]); } } ``` ## Middleware ```php header('X-Webhook-Signature'); $secret = config('sent-dm.webhook_secret'); if (empty($secret)) { Log::error('Webhook secret not configured'); return response()->json(['error' => 'Webhook not configured'], 500); } if (empty($signature)) { return response()->json(['error' => 'Missing signature'], 401); } $expected = hash_hmac('sha256', $request->getContent(), $secret); if (!hash_equals($expected, $signature)) { Log::warning('Invalid webhook signature', ['ip' => $request->ip()]); return response()->json(['error' => 'Invalid signature'], 401); } return $next($request); } } ``` Register in `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->alias(['sent-dm.webhook' => \App\Http\Middleware\VerifySentDMWebhook::class]); }) ``` ## Webhook Handling ### Migration ```php id(); $table->string('event_type', 100)->index(); $table->string('event_id', 100)->unique(); $table->uuid('message_id')->nullable()->index(); $table->string('status', 50)->nullable(); $table->json('payload'); $table->string('signature', 100)->nullable(); $table->ipAddress('source_ip')->nullable(); $table->timestamp('processed_at')->nullable(); $table->text('error')->nullable(); $table->timestamps(); $table->index(['event_type', 'created_at']); }); } public function down(): void { Schema::dropIfExists('webhook_logs'); } }; ``` ### Model & Events ```php 'array', 'processed_at' => 'datetime']; } ``` ```php all(); $signature = $request->header('X-Webhook-Signature'); $eventType = $request->header('X-Webhook-Event'); $eventId = $request->header('X-Webhook-Id'); $webhookLog = WebhookLog::create([ 'event_type' => $eventType ?? $payload['type'] ?? 'unknown', 'event_id' => $eventId ?? uniqid('webhook_', true), 'message_id' => $payload['data']['id'] ?? null, 'status' => $payload['data']['status'] ?? null, 'payload' => $payload, 'signature' => $signature, 'source_ip' => $request->ip(), ]); try { $this->processWebhook($eventType ?? $payload['type'], $payload); $webhookLog->update(['processed_at' => now()]); return response()->json(['received' => true]); } catch (\Exception $e) { Log::error('Webhook processing failed', ['event_id' => $webhookLog->event_id, 'error' => $e->getMessage()]); $webhookLog->update(['error' => $e->getMessage(), 'processed_at' => now()]); return response()->json(['received' => true]); } } private function processWebhook(?string $eventType, array $payload): void { match ($eventType) { 'message.status.updated' => $this->handleStatusUpdate($payload), 'message.delivered' => Log::info("Message delivered: {$payload['data']['id']}"), 'message.failed' => $this->handleFailed($payload), 'message.read' => Log::info("Message read: {$payload['data']['id']}"), default => Log::warning("Unhandled webhook: {$eventType}"), }; } private function handleStatusUpdate(array $payload): void { $data = $payload['data']; MessageStatusUpdated::dispatch(messageId: $data['id'], status: $data['status'], metadata: $data['metadata'] ?? null); Log::info("Status updated: {$data['id']} -> {$data['status']}"); } private function handleFailed(array $payload): void { $data = $payload['data']; MessageStatusUpdated::dispatch(messageId: $data['id'], status: 'failed', error: $data['error'] ?? null); Log::error("Message failed: {$data['id']}", ['error' => $data['error'] ?? null]); } } ``` ## Queue Jobs ```php uniqueId = 'sent-msg-' . md5($phoneNumber . $templateId . time()); } public function middleware(): array { return [(new RateLimitedWithRedis('sent-dm'))->dontRelease()]; } public function handle(SentDMServiceContract $sentService): void { Log::info('Processing message job', ['phone' => $this->phoneNumber, 'attempt' => $this->attempts()]); $result = $sentService->sendMessage($this->phoneNumber, $this->templateId, $this->templateName, $this->parameters, $this->channels, $this->testMode); if (!$result->success) { throw new \RuntimeException("Failed: {$result->error}"); } Log::info('Message job completed', ['message_id' => $result->messageId]); } public function failed(Throwable $exception): void { Log::error('Message job failed', ['phone' => $this->phoneNumber, 'error' => $exception->getMessage()]); } public function uniqueId(): string { return $this->uniqueId; } } ``` Dispatch jobs: ```php SendMessageJob::dispatch('+1234567890', 'template-id', 'welcome', ['name' => 'John'], ['whatsapp'])->onQueue('messages'); SendMessageJob::dispatch(...$args)->delay(now()->addMinutes(5)); ``` ## Testing ```php (object) ['messages' => [(object) ['id' => 'msg_123', 'status' => 'pending']]]]; $client->messages = m::mock(); $client->messages->shouldReceive('send')->once()->andReturn($mockResponse); $result = $service->sendMessage('+1234567890', 'tpl-123', 'welcome', [], ['whatsapp']); $this->assertTrue($result->success); $this->assertEquals('msg_123', $result->messageId); } public function test_send_message_failure(): void { $client = m::mock(Client::class); $service = new SentDMService($client); $client->messages = m::mock(); $client->messages->shouldReceive('send')->once()->andThrow(new BadRequestException('Invalid phone')); $result = $service->sendMessage('invalid', 'tpl', 'welcome'); $this->assertFalse($result->success); } public function test_verify_webhook_signature(): void { $service = new SentDMService(m::mock(Client::class)); $payload = '{"type":"test"}'; $secret = 'test-secret'; $signature = hash_hmac('sha256', $payload, $secret); config(['sent-dm.webhook_secret' => $secret]); $this->assertTrue($service->verifyWebhookSignature($payload, $signature)); $this->assertFalse($service->verifyWebhookSignature($payload, 'invalid')); } // Controller Tests public function test_can_send_message(): void { $user = \App\Models\User::factory()->create(); $sentService = m::mock(SentDMServiceContract::class); $sentService->shouldReceive('sendMessage')->once()->andReturn(new MessageResult(success: true, messageId: 'msg_123', status: 'queued')); $this->app->instance(SentDMServiceContract::class, $sentService); $response = $this->actingAs($user, 'sanctum')->postJson('/api/messages/send', [ 'phone_number' => '+1234567890', 'template_id' => 'tpl-123', 'template_name' => 'welcome' ]); $response->assertOk()->assertJson(['success' => true, 'data' => ['message_id' => 'msg_123']]); } public function test_validation_fails_with_invalid_phone(): void { $user = \App\Models\User::factory()->create(); $response = $this->actingAs($user, 'sanctum')->postJson('/api/messages/send', [ 'phone_number' => 'invalid', 'template_id' => 'tpl-123', 'template_name' => 'welcome' ]); $response->assertUnprocessable()->assertJsonValidationErrors(['phone_number']); } // Webhook Tests public function test_accepts_valid_webhook(): void { $payload = ['type' => 'message.status.updated', 'data' => ['id' => 'msg_123', 'status' => 'delivered']]; $signature = hash_hmac('sha256', json_encode($payload), 'test-webhook-secret'); $response = $this->postJson('/api/webhooks/sent-dm', $payload, [ 'X-Webhook-Signature' => $signature, 'X-Webhook-Event' => 'message.status.updated', 'X-Webhook-Id' => 'evt_123' ]); $response->assertOk()->assertJson(['received' => true]); $this->assertDatabaseHas('webhook_logs', ['event_type' => 'message.status.updated', 'message_id' => 'msg_123']); } public function test_rejects_invalid_signature(): void { $response = $this->postJson('/api/webhooks/sent-dm', ['type' => 'test'], ['X-Webhook-Signature' => 'invalid']); $response->assertUnauthorized()->assertJson(['error' => 'Invalid signature']); } // Job Tests public function test_job_sends_message(): void { $sentService = m::mock(SentDMServiceContract::class); $sentService->shouldReceive('sendMessage')->once()->andReturn(new MessageResult(success: true, messageId: 'msg_123')); $this->app->instance(SentDMServiceContract::class, $sentService); $job = new SendMessageJob('+1234567890', 'tpl-123', 'welcome'); $job->handle($sentService); $this->assertTrue(true); } public function test_job_can_be_dispatched(): void { Queue::fake(); SendMessageJob::dispatch('+1234567890', 'tpl-123', 'welcome'); Queue::assertPushed(SendMessageJob::class, fn ($job) => $job->phoneNumber === '+1234567890'); } } ``` ## Project Structure ``` app/ ├── Console/Commands/SendTestMessage.php ├── Events/MessageStatusUpdated.php ├── Http/ │ ├── Controllers/Api/MessageController.php │ ├── Controllers/WebhookController.php │ ├── Middleware/VerifySentDMWebhook.php │ ├── Requests/SendMessageRequest.php │ └── Requests/SendWelcomeRequest.php ├── Jobs/SendMessageJob.php ├── Models/WebhookLog.php ├── Providers/SentDMServiceProvider.php └── Services/SentDM/ ├── Contracts/SentDMServiceContract.php ├── Contracts/MessageResult.php └── SentDMService.php config/sent-dm.php database/migrations/2024_01_01_000001_create_webhook_logs_table.php routes/api.php tests/SentDMTest.php ``` ## Environment Variables ```bash SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret SENT_DM_BASE_URL=https://api.sent.dm SENT_DM_TIMEOUT=30 SENT_DM_MAX_RETRIES=3 SENT_DM_WEBHOOK_QUEUE=default SENT_DM_WEBHOOK_QUEUE_NAME=webhooks ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [PHP SDK reference](/sdks/php) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/php/integrations/symfony.txt TITLE: Symfony Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/php/integrations/symfony.txt Symfony bundle with dependency injection, DTOs, service layer, and async processing # Symfony Integration Complete Symfony 7 integration with dependency injection, configuration management, and modular architecture. This example uses `sentdm/sent-dm-php` with Symfony patterns including bundles, services, DTOs, and controllers. ## Project Structure ``` project/ ├── config/ │ ├── packages/sent_dm.yaml # Bundle configuration │ ├── packages/messenger.yaml # Async processing │ └── bundles.php # Bundle registration ├── src/SentDmBundle/ │ ├── SentDmBundle.php # Bundle entry │ ├── DependencyInjection/ │ │ ├── SentDmExtension.php # DI extension │ │ └── Configuration.php # Config tree │ ├── Controller/ │ │ └── MessagesController.php │ ├── Dto/ │ │ ├── SendMessageDto.php │ │ └── MessageResponseDto.php │ ├── Service/ │ │ └── SentDmService.php │ └── EventSubscriber/ │ └── WebhookEventSubscriber.php └── tests/ ├── Unit/SentDmServiceTest.php └── Functional/MessagesControllerTest.php ``` ## Installation ```bash composer require sentdm/sent-dm-php symfony/validator symfony/serializer composer require symfony/messenger symfony/redis-messenger # For async ``` ## Bundle Setup ```php // src/SentDmBundle/SentDmBundle.php namespace App\SentDmBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; use App\SentDmBundle\DependencyInjection\SentDmExtension; class SentDmBundle extends Bundle { public function getExtension(): ?\Symfony\Component\DependencyInjection\Extension\ExtensionInterface { return new SentDmExtension(); } } ``` ```php // src/SentDmBundle/DependencyInjection/SentDmExtension.php namespace App\SentDmBundle\DependencyInjection; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\Reference; use App\SentDmBundle\Service\SentDmService; use App\SentDmBundle\EventSubscriber\WebhookEventSubscriber; use SentDM\Client; class SentDmExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.yaml'); $config = $this->processConfiguration(new Configuration(), $configs); $container->register('sent_dm.client', Client::class) ->setArgument('$apiKey', $config['api_key']) ->setArgument('$maxRetries', $config['max_retries'] ?? 2); $container->getDefinition(SentDmService::class) ->setArgument('$client', new Reference('sent_dm.client')); if ($config['webhook']['enabled'] ?? false) { $container->getDefinition(WebhookEventSubscriber::class) ->setArgument('$webhookSecret', $config['webhook']['secret'] ?? null) ->addTag('kernel.event_subscriber'); } } public function getAlias(): string { return 'sent_dm'; } } ``` ```php // src/SentDmBundle/DependencyInjection/Configuration.php namespace App\SentDmBundle\DependencyInjection; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('sent_dm'); $rootNode = $treeBuilder->getRootNode(); $rootNode->children() ->scalarNode('api_key')->isRequired()->end() ->integerNode('max_retries')->defaultValue(2)->end() ->arrayNode('webhook')->addDefaultsIfNotSet()->children() ->booleanNode('enabled')->defaultValue(false)->end() ->scalarNode('secret')->defaultNull()->end() ->scalarNode('path')->defaultValue('/webhooks/sent')->end() ->end()->end() ->end(); return $treeBuilder; } } ``` ## Configuration ```yaml # config/packages/sent_dm.yaml sent_dm: api_key: '%env(SENT_DM_API_KEY)%' max_retries: 2 webhook: enabled: true secret: '%env(SENT_DM_WEBHOOK_SECRET)%' path: '/webhooks/sent' ``` ```yaml # src/SentDmBundle/Resources/config/services.yaml services: _defaults: autowire: true autoconfigure: true App\SentDmBundle\: resource: '../../*' exclude: ['../../DependencyInjection/', '../../Resources/'] App\SentDmBundle\Service\SentDmService: ~ App\SentDmBundle\EventSubscriber\WebhookEventSubscriber: ~ ``` ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret ``` ## DTOs ```php // src/SentDmBundle/Dto/SendMessageDto.php namespace App\SentDmBundle\Dto; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Serializer\Annotation as Serializer; class SendMessageDto { #[Assert\NotBlank] #[Assert\Regex(pattern: '/^\+[1-9]\d{1,14}$/', message: 'Phone number must be in E.164 format')] #[Serializer\Groups(['write'])] public string $phoneNumber; #[Assert\NotBlank] #[Assert\Uuid] #[Serializer\Groups(['write'])] public string $templateId; #[Assert\NotBlank] #[Assert\Length(min: 1, max: 100)] #[Serializer\Groups(['write'])] public string $templateName; #[Assert\Optional] #[Serializer\Groups(['write'])] public ?array $parameters = null; #[Assert\Optional] #[Assert\All([new Assert\Choice(choices: ['sms', 'whatsapp', 'telegram', 'viber'])])] #[Serializer\Groups(['write'])] public ?array $channels = null; #[Assert\Optional] #[Serializer\Groups(['write'])] public bool $testMode = false; public function toArray(): array { return [ 'phoneNumber' => $this->phoneNumber, 'templateId' => $this->templateId, 'templateName' => $this->templateName, 'parameters' => $this->parameters, 'channels' => $this->channels, 'testMode' => $this->testMode, ]; } } ``` ```php // src/SentDmBundle/Dto/MessageResponseDto.php namespace App\SentDmBundle\Dto; use Symfony\Component\Serializer\Annotation as Serializer; class MessageResponseDto { #[Serializer\Groups(['read'])] public string $messageId; #[Serializer\Groups(['read'])] public string $status; #[Serializer\Groups(['read'])] public ?string $channel = null; public function __construct(string $messageId, string $status, ?string $channel = null) { $this->messageId = $messageId; $this->status = $status; $this->channel = $channel; } } ``` ## Service Layer ```php // src/SentDmBundle/Service/SentDmService.php namespace App\SentDmBundle\Service; use Psr\Log\LoggerInterface; use SentDM\Client; use SentDM\Core\Exceptions\APIException; use App\SentDmBundle\Dto\MessageResponseDto; use App\SentDmBundle\Dto\SendMessageDto; class SentDmService { public function __construct( private readonly Client $client, private readonly LoggerInterface $logger, ) {} public function sendMessage(SendMessageDto $dto): MessageResponseDto { try { $response = $this->client->messages->send( to: [$dto->phoneNumber], template: [ 'id' => $dto->templateId, 'name' => $dto->templateName, 'parameters' => $dto->parameters ?? [], ], channels: $dto->channels, testMode: $dto->testMode, ); $message = $response->data->messages[0]; $this->logger->info('Message sent', ['message_id' => $message->id]); return new MessageResponseDto($message->id, $message->status, $message->channel ?? null); } catch (APIException $e) { $this->logger->error('Failed to send message', ['error' => $e->getMessage()]); throw $e; } } public function getMessageStatus(string $messageId): array { try { $response = $this->client->messages->get($messageId); return [ 'id' => $response->data->id, 'status' => $response->data->status, 'channel' => $response->data->channel ?? null, 'delivered_at' => $response->data->deliveredAt ?? null, ]; } catch (APIException $e) { $this->logger->error('Failed to get status', ['message_id' => $messageId]); throw $e; } } } ``` ## Controller ```php // src/SentDmBundle/Controller/MessagesController.php namespace App\SentDmBundle\Controller; use App\SentDmBundle\Dto\SendMessageDto; use App\SentDmBundle\Service\SentDmService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; #[Route('/api/messages')] class MessagesController extends AbstractController { public function __construct(private readonly SentDmService $sentDmService) {} #[Route('/send', methods: ['POST'])] public function send( #[MapRequestPayload] SendMessageDto $dto, RateLimiterFactory $sentDmMessageLimiter, ): JsonResponse { $limiter = $sentDmMessageLimiter->create($this->getUser()?->getUserIdentifier() ?? 'anonymous'); if (!$limiter->consume(1)->isAccepted()) { throw new TooManyRequestsHttpException(); } $response = $this->sentDmService->sendMessage($dto); return $this->json($response, 200, [], ['groups' => ['read']]); } #[Route('/{messageId}/status', methods: ['GET'])] public function status(string $messageId): JsonResponse { return $this->json($this->sentDmService->getMessageStatus($messageId)); } } ``` ```yaml # config/packages/rate_limiter.yaml framework: rate_limiter: sent_dm_message: policy: 'sliding_window' limit: 100 interval: '1 minute' ``` ## Webhook Handling ```php // src/SentDmBundle/EventSubscriber/WebhookEventSubscriber.php namespace App\SentDmBundle\EventSubscriber; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; class WebhookEventSubscriber implements EventSubscriberInterface { public function __construct( private readonly ?string $webhookSecret, private readonly LoggerInterface $logger, ) {} public static function getSubscribedEvents(): array { return [KernelEvents::REQUEST => ['onKernelRequest', 10]]; } public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); if ($request->getPathInfo() !== '/webhooks/sent' || !$request->isMethod('POST')) { return; } $signature = $request->headers->get('X-Webhook-Signature'); if (!$signature) { $event->setResponse(new JsonResponse(['error' => 'Missing signature'], Response::HTTP_UNAUTHORIZED)); return; } $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $this->webhookSecret ?? ''); if (!hash_equals($expected, $signature)) { $this->logger->warning('Invalid webhook signature', ['ip' => $request->getClientIp()]); $event->setResponse(new JsonResponse(['error' => 'Invalid signature'], Response::HTTP_UNAUTHORIZED)); return; } try { $eventData = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); $this->handleEvent($eventData); $event->setResponse(new JsonResponse(['received' => true])); } catch (\JsonException $e) { $event->setResponse(new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST)); } } private function handleEvent(array $eventData): void { $type = $eventData['type'] ?? 'unknown'; $data = $eventData['data'] ?? []; match ($type) { 'message.status.updated' => $this->logger->info('Status updated', ['id' => $data['id']]), 'message.delivered' => $this->logger->info('Message delivered', ['id' => $data['id']]), 'message.failed' => $this->logger->error('Message failed', ['id' => $data['id'], 'error' => $data['error']['message'] ?? '']), default => $this->logger->warning('Unhandled event', ['type' => $type]), }; } } ``` ## Testing ### Service Tests ```php // tests/Unit/SentDmBundle/Service/SentDmServiceTest.php namespace App\Tests\Unit\SentDmBundle\Service; use App\SentDmBundle\Dto\MessageResponseDto; use App\SentDmBundle\Dto\SendMessageDto; use App\SentDmBundle\Service\SentDmService; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use SentDM\Client; use SentDM\Core\Exceptions\BadRequestException; class SentDmServiceTest extends TestCase { private $client; private $service; protected function setUp(): void { $this->client = $this->createMock(Client::class); $this->service = new SentDmService($this->client, $this->createMock(LoggerInterface::class)); } public function testSendMessageSuccess(): void { $dto = new SendMessageDto(); $dto->phoneNumber = '+1234567890'; $dto->templateId = '7ba7b820-9dad-11d1-80b4-00c04fd430c8'; $dto->templateName = 'welcome'; $mockResponse = (object) ['data' => (object) ['messages' => [(object) ['id' => 'msg_123', 'status' => 'pending', 'channel' => 'whatsapp']]]]; $this->client->messages = $this->createMock(\stdClass::class); $this->client->messages->method('send')->willReturn($mockResponse); $result = $this->service->sendMessage($dto); $this->assertInstanceOf(MessageResponseDto::class, $result); $this->assertEquals('msg_123', $result->messageId); } public function testSendMessageFailure(): void { $dto = new SendMessageDto(); $dto->phoneNumber = '+1234567890'; $dto->templateId = '7ba7b820-9dad-11d1-80b4-00c04fd430c8'; $dto->templateName = 'welcome'; $this->client->messages = $this->createMock(\stdClass::class); $this->client->messages->method('send')->willThrowException(new BadRequestException('Invalid phone')); $this->expectException(\SentDM\Core\Exceptions\APIException::class); $this->service->sendMessage($dto); } } ``` ```php // tests/Functional/SentDmBundle/Controller/MessagesControllerTest.php namespace App\Tests\Functional\SentDmBundle\Controller; use App\SentDmBundle\Service\SentDmService; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Response; class MessagesControllerTest extends WebTestCase { public function testSendMessageSuccess(): void { $client = static::createClient(); // Mock the service to avoid actual API calls $sentDmService = $this->createMock(SentDmService::class); $sentDmService->expects($this->once()) ->method('sendMessage') ->willReturn(new \App\SentDmBundle\Dto\MessageResponseDto('msg_123', 'pending', 'whatsapp')); $client->getContainer()->set(SentDmService::class, $sentDmService); $client->request('POST', '/api/messages/send', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ 'phoneNumber' => '+1234567890', 'templateId' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'templateName' => 'welcome', 'channels' => ['whatsapp'], ])); $this->assertResponseStatusCodeSame(Response::HTTP_OK); $this->assertJsonContains(['messageId' => 'msg_123', 'status' => 'pending']); } public function testSendMessageValidation(): void { $client = static::createClient(); $client->request('POST', '/api/messages/send', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ 'phoneNumber' => 'invalid', 'templateId' => 'not-a-uuid', 'templateName' => '', ])); $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); } public function testGetMessageStatus(): void { $client = static::createClient(); $sentDmService = $this->createMock(SentDmService::class); $sentDmService->expects($this->once()) ->method('getMessageStatus') ->with('msg_123') ->willReturn(['id' => 'msg_123', 'status' => 'delivered', 'channel' => 'whatsapp']); $client->getContainer()->set(SentDmService::class, $sentDmService); $client->request('GET', '/api/messages/msg_123/status'); $this->assertResponseStatusCodeSame(Response::HTTP_OK); $this->assertJsonContains(['status' => 'delivered']); } } ``` ### Webhook Tests ```php // tests/Integration/SentDmBundle/WebhookHandlingTest.php namespace App\Tests\Integration\SentDmBundle; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Response; class WebhookHandlingTest extends WebTestCase { public function testWebhookWithValidSignature(): void { $client = static::createClient(); $payload = ['type' => 'message.status.updated', 'data' => ['id' => 'msg_123', 'status' => 'delivered']]; $signature = 'sha256=' . hash_hmac('sha256', json_encode($payload), 'test-webhook-secret'); $client->request('POST', '/webhooks/sent', [], [], [ 'CONTENT_TYPE' => 'application/json', 'HTTP_X_WEBHOOK_SIGNATURE' => $signature, ], json_encode($payload)); $this->assertResponseStatusCodeSame(Response::HTTP_OK); } public function testWebhookWithInvalidSignature(): void { $client = static::createClient(); $client->request('POST', '/webhooks/sent', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['type' => 'test'])); $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); } } ``` ### Running Tests ```bash # Run all tests php bin/phpunit # Run only unit tests php bin/phpunit --testsuite Unit # Run only functional tests php bin/phpunit --testsuite Functional # Run with coverage report php bin/phpunit --coverage-html coverage ``` ## Environment Variables ```php // config/bundles.php return [ // ... other bundles App\SentDmBundle\SentDmBundle::class => ['all' => true], ]; ``` ```xml tests/Unit tests/Functional ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [PHP SDK reference](/sdks/php) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python.txt TITLE: Python SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/python.txt Official Python SDK for Sent. Send SMS and WhatsApp messages with Pythonic elegance and full async support. # Python SDK The official Python SDK for Sent LogoSent provides a clean, Pythonic interface to the Sent API. Built for developers who value readability, with optional async support for high-performance applications. ## Installation ```bash pip install sentdm ``` ```bash poetry add sentdm ``` ```bash uv pip install sentdm ``` ## Quick Start ### Initialize the client ```python from sent_dm import SentDm client = SentDm() # Uses SENT_DM_API_KEY env var by default ``` ### Send your first message ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": { "name": "John Doe" } } ) print(f"Message sent: {response.data.messages[0].id}") print(f"Status: {response.data.messages[0].status}") ``` ## Authentication While you can provide an `api_key` keyword argument, we recommend using `python-dotenv` to add `SENT_DM_API_KEY="your_api_key"` to your `.env` file so that your API Key is not stored in source control. ```python from sent_dm import SentDm # Using environment variables (recommended) client = SentDm() # Or explicit configuration client = SentDm( api_key="your_api_key", ) ``` ## Async usage Simply import `AsyncSentDm` instead of `SentDm` and use `await` with each API call: ```python import asyncio from sent_dm import AsyncSentDm client = AsyncSentDm() async def main(): response = await client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": { "name": "John Doe" } } ) print(f"Sent: {response.data.messages[0].id}") asyncio.run(main()) ``` Functionality between the synchronous and asynchronous clients is otherwise identical. ### With aiohttp By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. ```bash pip install sentdm[aiohttp] ``` ```python import asyncio from sent_dm import DefaultAioHttpClient from sent_dm import AsyncSentDm async def main(): async with AsyncSentDm( http_client=DefaultAioHttpClient(), ) as client: response = await client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": {"name": "John Doe"} } ) print(f"Sent: {response.data.messages[0].id}") asyncio.run(main()) ``` ## Send Messages ### Send a message ```python from sent_dm import SentDm client = SentDm() response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome", "parameters": { "name": "John Doe", "order_id": "12345" } }, channel=["whatsapp", "sms"] # Optional: defaults to template channels ) print(f"Message ID: {response.data.messages[0].id}") print(f"Status: {response.data.messages[0].status}") ``` ### Test mode Use `test_mode=True` to validate requests without sending real messages: ```python response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" }, test_mode=True # Validates but doesn't send ) # Response will have test data print(f"Validation passed: {response.data.messages[0].id}") ``` ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `sent_dm.APIConnectionError` is raised. When the API returns a non-success status code (that is, 4xx or 5xx response), a subclass of `sent_dm.APIStatusError` is raised, containing `status_code` and `response` properties. ```python import sent_dm from sent_dm import SentDm client = SentDm() try: response = client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(f"Sent: {response.data.messages[0].id}") except sent_dm.APIConnectionError as e: print("The server could not be reached") print(e.__cause__) # an underlying Exception, likely raised within httpx. except sent_dm.RateLimitError as e: print("A 429 status code was received; we should back off a bit.") except sent_dm.APIStatusError as e: print("Another non-200-range status code was received") print(e.status_code) print(e.response) ``` Error codes are as follows: | Status Code | Error Type | |-------------|--------------------------| | 400 | `BadRequestError` | | 401 | `AuthenticationError` | | 403 | `PermissionDeniedError` | | 404 | `NotFoundError` | | 422 | `UnprocessableEntityError` | | 429 | `RateLimitError` | | >=500 | `InternalServerError` | | N/A | `APIConnectionError` | ## Retries Certain errors are automatically retried 2 times by default, with a short exponential backoff. Connection errors, 408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errors are all retried by default. ```python from sent_dm import SentDm # Configure the default for all requests: client = SentDm( max_retries=0, # default is 2 ) # Or, configure per-request: client.with_options(max_retries=5).messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) ``` ## Timeouts By default requests time out after 1 minute. You can configure this with a `timeout` option: ```python import httpx from sent_dm import SentDm # Configure the default for all requests: client = SentDm( timeout=20.0, # 20 seconds (default is 1 minute) ) # More granular control: client = SentDm( timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), ) # Override per-request: client.with_options(timeout=5.0).messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) ``` ## Contacts Create and manage contacts: ```python # Create a contact response = client.contacts.create( phone_number="+1234567890" ) print(f"Contact ID: {response.data.id}") # List contacts responses = client.contacts.list(limit=100) for contact in responses.data: print(f"{contact.phone_number} - {contact.available_channels}") # Get a contact response = client.contacts.get("contact-uuid") # Update a contact response = client.contacts.update( "contact-uuid", phone_number="+1987654321" ) # Delete a contact client.contacts.delete("contact-uuid") ``` ## Templates List and retrieve templates: ```python # List all templates response = client.templates.list() for template in response.data: print(f"{template.name} ({template.status}): {template.id}") # Get a specific template response = client.templates.get("template-uuid") print(f"Name: {response.data.name}") print(f"Status: {response.data.status}") ``` ## Django Integration ```python # settings.py SENT_DM_API_KEY = os.environ.get("SENT_DM_API_KEY") # utils.py from sent_dm import SentDm from django.conf import settings _sent_client = None def get_sent_client(): global _sent_client if _sent_client is None: _sent_client = SentDm(api_key=settings.SENT_DM_API_KEY) return _sent_client # views.py from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from .utils import get_sent_client import json @require_http_methods(["POST"]) def send_message(request): client = get_sent_client() data = json.loads(request.body) try: response = client.messages.send( to=[data.get("phone_number")], template={ "id": data.get("template_id"), "name": "welcome", "parameters": data.get("variables", {}) } ) return JsonResponse({ "success": True, "message_id": response.data.messages[0].id, "status": response.data.messages[0].status }) except Exception as e: return JsonResponse( {"error": str(e)}, status=400 ) ``` ## FastAPI Integration ```python from fastapi import FastAPI, HTTPException from sent_dm import SentDm import os app = FastAPI() # Initialize client client = SentDm() @app.post("/send-message") async def send_message(phone_number: str, template_id: str): try: response = client.messages.send( to=[phone_number], template={ "id": template_id, "name": "welcome" } ) return { "message_id": response.data.messages[0].id, "status": response.data.messages[0].status, } except Exception as e: raise HTTPException(status_code=400, detail=str(e)) ``` ## Raw responses To access response headers, status code, or raw body, prefix any HTTP method call with `.with_raw_response.`: ```python response = client.with_raw_response.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) print(response.status_code) # 200 print(response.headers.get("x-request-id")) # Request ID for support # Deserialize the body data = response.body print(data.messages[0].id) ``` ## Streaming responses The async client supports streaming responses for large data: ```python from sent_dm import AsyncSentDm client = AsyncSentDm() async with client.messages.with_streaming_response.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" } ) as response: async for chunk in response.iter_bytes(): print(chunk) ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. ```python import base64 import hashlib import hmac import json import os import time from flask import Flask, request, jsonify app = Flask(__name__) @app.post('/webhooks/sent') def handle_webhook(): payload = request.get_data() # raw bytes — do NOT parse JSON first webhook_id = request.headers.get('X-Webhook-ID', '') timestamp = request.headers.get('X-Webhook-Timestamp', '') signature = request.headers.get('X-Webhook-Signature', '') # 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" secret = os.environ['SENT_WEBHOOK_SECRET'] # "whsec_abc123..." key_bytes = base64.b64decode(secret.removeprefix('whsec_')) signed = f"{webhook_id}.{timestamp}.{payload.decode('utf-8')}" digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest() expected = 'v1,' + base64.b64encode(digest).decode() if not hmac.compare_digest(signature, expected): return jsonify({'error': 'Invalid signature'}), 401 # 2. Optional: reject replayed events older than 5 minutes if abs(time.time() - int(timestamp)) > 300: return jsonify({'error': 'Timestamp too old'}), 401 event = json.loads(payload) # 3. Handle events — update message status in your own database if event.get('field') == 'messages': msg = event['payload'] # db.messages.filter(sent_id=msg['message_id']).update(status=msg['message_status']) print(f"Message {msg['message_id']} → {msg['message_status']}") # 4. Always return 200 quickly return jsonify({'received': True}) ``` See the [Webhooks reference](/reference/api/webhooks) for the full payload schema and all status values. ## Logging We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. You can enable logging by setting the environment variable `SENT_DM_LOG` to `info`: ```bash export SENT_DM_LOG=info ``` Or to `debug` for more verbose logging. ## Source & Issues - **Version**: 0.18.0 - **GitHub**: [sentdm/sent-dm-python](https://github.com/sentdm/sent-dm-python) - **PyPI**: [sentdm](https://pypi.org/project/sentdm/) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-python/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/celery.txt TITLE: Celery Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/celery.txt Production-ready background task processing with Celery, including retry strategies, monitoring, and workflow orchestration # Celery Integration Complete Celery integration with task base classes, retry strategies, workflow orchestration, monitoring, and database integration for production workloads. This guide covers Celery 5.3+ best practices with Redis/RabbitMQ broker, result backend, task signals, and Flower monitoring. ## Project Structure ``` project/ ├── celery_app/ │ ├── __init__.py │ ├── app.py # Celery app configuration │ ├── config.py # Configuration classes │ ├── base_task.py # Base task with retry logic │ ├── rate_limiter.py # Token bucket rate limiting │ └── exceptions.py # Custom exceptions ├── tasks/ │ ├── __init__.py │ ├── messages.py # Message sending tasks │ ├── bulk.py # Bulk operation tasks │ └── workflows.py # Canvas workflow definitions ├── models/ │ └── message_log.py # Database models ├── tests/ │ ├── conftest.py # pytest fixtures │ └── test_tasks.py # Task tests ├── docker-compose.yml # Stack configuration └── requirements.txt ``` ## Installation ```bash pip install celery[redis] sentdm sqlalchemy psycopg2-binary flower prometheus-client ``` ## Configuration ### Celery Configuration ```python # celery_app/config.py import os from typing import Dict, Any class CeleryConfig: """Base Celery configuration""" broker_url: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") broker_connection_retry_on_startup: bool = True result_backend: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") result_expires: int = 3600 * 24 task_serializer: str = "json" accept_content: list = ["json"] task_track_started: bool = True task_time_limit: int = 300 task_soft_time_limit: int = 240 task_acks_late: bool = True worker_prefetch_multiplier: int = 1 worker_max_tasks_per_child: int = 1000 task_routes: Dict[str, str] = { "tasks.messages.*": {"queue": "messages"}, "tasks.bulk.*": {"queue": "bulk"}, "tasks.workflows.*": {"queue": "workflows"}, } beat_schedule: Dict[str, Any] = { "cleanup-old-logs": { "task": "tasks.scheduled.cleanup_message_logs", "schedule": 3600 * 24, "args": (30,), }, "retry-failed-messages": { "task": "tasks.scheduled.retry_failed_messages", "schedule": 300, }, } database_url: str = os.getenv("DATABASE_URL", "postgresql://localhost/celery_tasks") sent_dm_api_key: str = os.getenv("SENT_DM_API_KEY", "") class ProductionConfig(CeleryConfig): """Production configuration""" broker_transport_options: Dict[str, Any] = { "visibility_timeout": 43200, "queue_order_strategy": "priority", } worker_enable_remote_control: bool = True worker_send_task_events: bool = True task_send_sent_event: bool = True def get_config(): env = os.getenv("CELERY_ENV", "development") return ProductionConfig() if env == "production" else CeleryConfig() ``` ### Celery App Factory ```python # celery_app/app.py from celery import Celery from .config import get_config def create_celery_app(app_name: str = "sent_dm_tasks") -> Celery: """Create and configure Celery application""" config = get_config() app = Celery(app_name) app.config_from_object(config) app.autodiscover_tasks([ "tasks.messages", "tasks.bulk", "tasks.workflows", ]) return app app = create_celery_app() ``` ## Custom Exceptions ```python # celery_app/exceptions.py from typing import Optional, Dict, Any class SentDMTaskError(Exception): """Base exception for SentDM Celery tasks""" def __init__(self, message: str, error_code: Optional[str] = None, details: Optional[Dict] = None, retryable: bool = False): super().__init__(message) self.message = message self.error_code = error_code or "TASK_ERROR" self.details = details or {} self.retryable = retryable class NonRetryableError(SentDMTaskError): """Error that should not be retried (4xx errors)""" def __init__(self, message: str, error_code: str = "NON_RETRYABLE", details=None): super().__init__(message, error_code, details, retryable=False) class RateLimitExceeded(SentDMTaskError): """Rate limit exceeded - retry with backoff""" def __init__(self, message: str = "Rate limit exceeded", retry_after: int = 60): super().__init__(message, "RATE_LIMIT_EXCEEDED", {"retry_after": retry_after}, True) self.retry_after = retry_after class TemporaryError(SentDMTaskError): """Temporary error - should be retried""" def __init__(self, message: str, error_code: str = "TEMPORARY_ERROR", details=None): super().__init__(message, error_code, details, retryable=True) ``` ## Token Bucket Rate Limiter ```python # celery_app/rate_limiter.py import time import threading from typing import Optional, Dict from dataclasses import dataclass @dataclass class TokenBucket: """Token bucket for rate limiting""" capacity: int tokens: float fill_rate: float last_update: float def consume(self, tokens: int = 1) -> bool: now = time.time() elapsed = now - self.last_update self.tokens = min(self.capacity, self.tokens + elapsed * self.fill_rate) self.last_update = now if self.tokens >= tokens: self.tokens -= tokens return True return False class RateLimiter: """Thread-safe rate limiter with multiple buckets""" def __init__(self): self._buckets: Dict[str, TokenBucket] = {} self._lock = threading.Lock() def acquire(self, key: str, capacity: int, fill_rate: float, tokens: int = 1, timeout: Optional[float] = None) -> bool: bucket = self._buckets.get(key) if not bucket: bucket = TokenBucket(capacity, capacity, fill_rate, time.time()) self._buckets[key] = bucket start_time = time.time() while True: with self._lock: if bucket.consume(tokens): return True wait_time = bucket.get_wait_time(tokens) if timeout and (time.time() - start_time + wait_time > timeout): return False if wait_time > 0: time.sleep(min(wait_time, 0.1)) rate_limiter = RateLimiter() ``` ## Base Task Class ```python # celery_app/base_task.py import logging from celery import Task from celery.exceptions import MaxRetriesExceededError, SoftTimeLimitExceeded from .exceptions import NonRetryableError, RateLimitExceeded, TemporaryError from .rate_limiter import rate_limiter logger = logging.getLogger(__name__) class SentDMBaseTask(Task): """Base task class with retry logic, rate limiting, and error handling""" abstract = True max_retries = 3 default_retry_delay = 60 retry_backoff = True retry_backoff_max = 600 retry_jitter = True rate_limit_key: Optional[str] = None rate_limit_capacity: int = 100 rate_limit_fill_rate: float = 10.0 soft_time_limit = 240 time_limit = 300 def __init__(self): self._sent_client = None @property def sent_client(self): """Lazy initialization of SentDM client""" if self._sent_client is None: from sent_dm import SentDm import os api_key = os.getenv("SENT_DM_API_KEY") if not api_key: raise NonRetryableError("SENT_DM_API_KEY not configured") self._sent_client = SentDm(api_key) return self._sent_client def apply_rate_limit(self, tokens: int = 1) -> None: if not self.rate_limit_key: return acquired = rate_limiter.acquire( key=self.rate_limit_key, capacity=self.rate_limit_capacity, fill_rate=self.rate_limit_fill_rate, tokens=tokens, timeout=30, ) if not acquired: raise RateLimitExceeded(retry_after=int(1 / self.rate_limit_fill_rate)) def call(self, *args, **kwargs): self.apply_rate_limit() return super().call(*args, **kwargs) def on_retry(self, exc, task_id, args, kwargs, einfo): logger.warning(f"Task {task_id} retry {self.request.retries}/{self.max_retries}: {exc}") super().on_retry(exc, task_id, args, kwargs, einfo) def on_failure(self, exc, task_id, args, kwargs, einfo): logger.error(f"Task {task_id} failed: {exc}", extra={ "task_id": task_id, "exception": str(exc), }) super().on_failure(exc, task_id, args, kwargs, einfo) def on_success(self, retval, task_id, args, kwargs): logger.info(f"Task {task_id} completed successfully") super().on_success(retval, task_id, args, kwargs) ``` ## Message Tasks ```python # tasks/messages.py import logging from typing import Optional, Dict, Any from datetime import datetime from celery import shared_task from celery.exceptions import SoftTimeLimitExceeded from celery_app.base_task import SentDMBaseTask from celery_app.exceptions import NonRetryableError, RateLimitExceeded, TemporaryError, ValidationError logger = logging.getLogger(__name__) @shared_task( base=SentDMBaseTask, bind=True, name="tasks.messages.send_single_message", queue="messages", max_retries=3, default_retry_delay=60, ) def send_single_message( self, phone_number: str, template_id: str, variables: Optional[Dict[str, Any]] = None, channel: Optional[list] = None, track_id: Optional[str] = None, ) -> Dict[str, Any]: """Send a single message with full retry logic and error handling""" # Validate inputs if not phone_number or not phone_number.startswith("+"): raise ValidationError("Invalid phone number format. Must be E.164") try: result = self.sent_client.messages.send( to=[phone_number], template={ "id": template_id, "name": template_id, "parameters": variables or {} }, channel=channel, ) return { "success": True, "message_id": result.data.id if hasattr(result, "data") else None, "status": "sent", "track_id": track_id, "phone_number": phone_number, } except SoftTimeLimitExceeded: raise TemporaryError("Task timed out", error_code="TIMEOUT") except Exception as exc: error_msg = str(exc) if "rate limit" in error_msg.lower() or "429" in error_msg: retry_after = 60 * (2 ** self.request.retries) raise RateLimitExceeded(retry_after=retry_after) elif "invalid" in error_msg.lower() or "400" in error_msg: raise NonRetryableError(f"Invalid request: {error_msg}", "INVALID_REQUEST") elif self.request.retries < self.max_retries: raise self.retry(exc=exc) else: raise @shared_task(base=SentDMBaseTask, bind=True, name="tasks.messages.send_templated_batch", queue="messages") def send_templated_batch( self, recipients: list, template_id: str, batch_variables: Optional[Dict] = None, ) -> Dict[str, Any]: """Send messages to multiple recipients""" results = {"total": len(recipients), "queued": 0, "failed": 0, "errors": []} for recipient in recipients: try: send_single_message.delay( phone_number=recipient.get("phone_number"), template_id=template_id, variables={**(batch_variables or {}), **recipient.get("variables", {})}, ) results["queued"] += 1 except Exception as e: results["failed"] += 1 results["errors"].append({"recipient": recipient.get("phone_number"), "error": str(e)}) return results @shared_task(base=SentDMBaseTask, bind=True, name="tasks.messages.retry_failed_message", queue="messages") def retry_failed_message(self, message_log_id: str) -> Dict[str, Any]: """Retry a previously failed message by log ID""" # Implementation for retrying failed messages pass # Add your implementation ``` ## Bulk Operation Tasks ```python # tasks/bulk.py import logging from typing import List, Dict, Any, Optional from celery import shared_task, group, chord from celery_app.base_task import SentDMBaseTask from tasks.messages import send_single_message logger = logging.getLogger(__name__) @shared_task(base=SentDMBaseTask, bind=True, name="tasks.bulk.send_bulk_messages", queue="bulk") def send_bulk_messages( self, recipients: List[Dict], template_id: str, variables: Optional[Dict] = None, ) -> Dict[str, Any]: """Send messages to multiple users using groups for parallel processing""" if not recipients: return {"success": True, "queued": 0} signatures = [ send_single_message.s( phone_number=r["phone_number"], template_id=template_id, variables={**(variables or {}), **r.get("variables", {})}, ) for r in recipients ] job = group(signatures) result = job.apply_async() return { "success": True, "group_id": result.id, "total_tasks": len(signatures), } @shared_task(base=SentDMBaseTask, bind=True, name="tasks.bulk.validate_contacts", queue="bulk") def validate_contacts(self, contacts: List[Dict]) -> Dict[str, List]: """Validate and filter contacts""" import re valid, invalid = [], [] phone_pattern = re.compile(r"^\+[1-9]\d{1,14}$") for contact in contacts: phone = contact.get("phone_number", "").strip() if phone_pattern.match(phone): valid.append({**contact, "phone_number": phone}) else: invalid.append({"contact": contact, "reason": "Invalid phone format"}) return {"valid": valid, "invalid": invalid} # ... additional bulk task implementations ``` ## Canvas Workflows ```python # tasks/workflows.py """ Celery Canvas workflow examples: - chain: Sequential execution (A → B → C) - group: Parallel execution ([A, B, C]) - chord: Group with callback ([A, B, C] → D) """ import logging from datetime import datetime from typing import List, Dict, Any, Optional from celery import shared_task, chain, group, chord from celery_app.base_task import SentDMBaseTask from tasks.messages import send_single_message logger = logging.getLogger(__name__) # ============================================================================ # Chain Workflow (Sequential) # ============================================================================ @shared_task(base=SentDMBaseTask, bind=True, name="tasks.workflows.welcome_sequence", queue="workflows") def welcome_sequence(self, phone_number: str, user_name: str) -> Dict[str, Any]: """Multi-step welcome sequence using chain""" workflow = chain( send_single_message.s( phone_number=phone_number, template_id="welcome", variables={"name": user_name} ), send_single_message.si( phone_number=phone_number, template_id="onboarding-tips", variables={"name": user_name} ).set(countdown=3600), # 1 hour delay send_single_message.si( phone_number=phone_number, template_id="feature-highlight", variables={"name": user_name} ).set(countdown=86400), # 24 hour delay ) result = workflow.apply_async() return { "success": True, "workflow_id": result.id, "sequence": ["welcome", "onboarding-tips", "feature-highlight"], } # ============================================================================ # Group Workflow (Parallel) # ============================================================================ @shared_task(base=SentDMBaseTask, bind=True, name="tasks.workflows.multi_channel_notify", queue="workflows") def multi_channel_notify(self, phone_number: str, message_content: Dict) -> Dict[str, Any]: """Send same message via multiple channels in parallel""" channels = ["whatsapp", "sms"] tasks = [ send_single_message.s( phone_number=phone_number, template_id=message_content["template_id"], variables=message_content.get("variables", {}), channel=[channel], ) for channel in channels ] result = group(tasks).apply_async() return {"success": True, "group_id": result.id, "channels": channels} # ============================================================================ # Chord Workflow (Parallel + Callback) # ============================================================================ @shared_task(base=SentDMBaseTask, bind=True, name="tasks.workflows.campaign_with_report", queue="workflows") def campaign_with_report( self, recipients: List[Dict], template_id: str, campaign_id: str, ) -> Dict[str, Any]: """Send campaign messages and generate report when complete""" message_tasks = [ send_single_message.s( phone_number=r["phone_number"], template_id=template_id, variables=r.get("variables", {}) ) for r in recipients ] callback = generate_campaign_report.s(campaign_id=campaign_id, total_recipients=len(recipients)) result = chord(message_tasks)(callback) return {"success": True, "chord_id": result.id, "campaign_id": campaign_id, "recipients": len(recipients)} @shared_task(base=SentDMBaseTask, bind=True, name="tasks.workflows.generate_campaign_report", queue="workflows") def generate_campaign_report( self, results: List[Dict], campaign_id: str, total_recipients: int, ) -> Dict[str, Any]: """Generate campaign report from all message results""" successful = sum(1 for r in results if isinstance(r, dict) and r.get("success")) failed = len(results) - successful report = { "campaign_id": campaign_id, "timestamp": datetime.utcnow().isoformat(), "summary": { "total": total_recipients, "successful": successful, "failed": failed, "success_rate": round(successful / total_recipients * 100, 2) if total_recipients else 0, }, } logger.info(f"Campaign report generated: {report['summary']}") return report # ============================================================================ # Complex Workflow # ============================================================================ @shared_task(base=SentDMBaseTask, bind=True, name="tasks.workflows.escalation_workflow", queue="workflows") def escalation_workflow(self, alert_id: str, escalation_levels: List[Dict]) -> Dict[str, Any]: """Multi-level escalation workflow with delays between levels""" tasks = [] for i, level in enumerate(escalation_levels): task = send_single_message.si( phone_number=level["phone_number"], template_id=level["template_id"], variables={"alert_id": alert_id, "level": i + 1, **level.get("variables", {})}, ) if i > 0: task = task.set(countdown=level.get("delay_seconds", 300)) tasks.append(task) workflow = chain(*tasks) result = workflow.apply_async() return {"success": True, "workflow_id": result.id, "alert_id": alert_id, "levels": len(escalation_levels)} # ... additional workflow implementations (ab_test_campaign, broadcast_to_segments, etc.) ``` ## Testing ```python # tests/conftest.py import pytest import os os.environ["CELERY_BROKER_URL"] = "memory://" os.environ["CELERY_RESULT_BACKEND"] = "cache+memory://" @pytest.fixture(scope="session") def celery_config(): return { "broker_url": "memory://", "result_backend": "cache+memory://", "task_always_eager": True, "task_serializer": "json", } @pytest.fixture def mock_sent_client(monkeypatch): """Mock SentDM client""" class MockResult: def __init__(self): self.data = type("Data", (), {"id": "msg_123"})() class MockSentClient: def __init__(self): self.calls = [] def send(self, **kwargs): self.calls.append(kwargs) return MockResult() mock = MockSentClient() monkeypatch.setenv("SENT_DM_API_KEY", "test_key") monkeypatch.setattr("sent_dm.SentDm", lambda *a, **k: mock) return mock ``` ```python # tests/test_tasks.py import pytest from tasks.messages import send_single_message from tasks.bulk import validate_contacts from celery_app.exceptions import ValidationError class TestSendSingleMessage: def test_successful_send(self, celery_app, mock_sent_client): result = send_single_message.run( phone_number="+1234567890", template_id="welcome", variables={"name": "John"} ) assert result["success"] is True assert result["phone_number"] == "+1234567890" def test_invalid_phone_format(self, celery_app): with pytest.raises(ValidationError) as exc_info: send_single_message.run(phone_number="1234567890", template_id="welcome") assert "Invalid phone number format" in str(exc_info.value) class TestBulkOperations: def test_validate_contacts(self, celery_app): contacts = [ {"phone_number": "+1234567890", "name": "John"}, {"phone_number": "invalid", "name": "Jane"}, ] result = validate_contacts.run(contacts) assert len(result["valid"]) == 1 assert len(result["invalid"]) == 1 # ... additional test classes (TestRateLimiter, TestWorkflows, etc.) ``` ## Docker Compose ```yaml # docker-compose.yml version: "3.8" services: redis: image: redis:7-alpine ports: ["6379:6379"] volumes: [redis_data:/data] postgres: image: postgres:15-alpine environment: POSTGRES_USER: celery POSTGRES_PASSWORD: celery POSTGRES_DB: celery_tasks ports: ["5432:5432"] volumes: [postgres_data:/var/lib/postgresql/data] flower: build: . command: celery -A celery_app.app flower --port=5555 --basic_auth=admin:admin ports: ["5555:5555"] environment: CELERY_BROKER_URL: redis://redis:6379/0 worker: build: . command: celery -A celery_app.app worker --loglevel=info --queues=messages,bulk,workflows environment: CELERY_BROKER_URL: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/1 DATABASE_URL: postgresql://celery:celery@postgres:5432/celery_tasks SENT_DM_API_KEY: ${SENT_DM_API_KEY} depends_on: [redis, postgres] beat: build: . command: celery -A celery_app.app beat --loglevel=info environment: CELERY_BROKER_URL: redis://redis:6379/0 depends_on: [redis, postgres] volumes: redis_data: postgres_data: ``` ## Environment Variables ```bash # Required SENT_DM_API_KEY=your_api_key_here # Broker & Backend CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://localhost:6379/1 # Database DATABASE_URL=postgresql://user:pass@localhost/celery_tasks # Environment CELERY_ENV=development # or production ``` ## Running Celery ```bash # Development redis-server celery -A celery_app.app worker --loglevel=info --queues=messages,bulk,default celery -A celery_app.app beat --loglevel=info # Production (Docker) docker-compose up -d docker-compose logs -f worker ``` ## Usage Examples ```python from tasks.messages import send_single_message from tasks.workflows import welcome_sequence, campaign_with_report from datetime import datetime, timedelta # Basic task task = send_single_message.delay( phone_number="+1234567890", template_id="welcome", variables={"name": "John"} ) # Workflow workflow = welcome_sequence.delay(phone_number="+1234567890", user_name="John") # Scheduled task send_single_message.apply_async( kwargs={"phone_number": "+1234567890", "template_id": "reminder"}, eta=datetime(2024, 6, 15, 9, 0), ) # With retry policy send_single_message.apply_async( kwargs={"phone_number": "+1234567890", "template_id": "important"}, retry=True, retry_policy={"max_retries": 5, "interval_start": 60, "interval_max": 600}, ) ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Python SDK reference](/sdks/python) for advanced features - Explore [testing strategies](/sdks/testing) for background task validation ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/django.txt TITLE: Django Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/django.txt Complete Django integration with AppConfig, forms, class-based views, middleware, and Celery # Django Integration Complete Django integration with AppConfig initialization, forms, class-based views, middleware, signals, and Celery support. This example follows Django 5.0 best practices including proper AppConfig, class-based views, and async-ready patterns. ## Project Structure ``` myproject/ ├── myproject/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── sent_integration/ │ ├── __init__.py │ ├── apps.py # AppConfig with initialization │ ├── admin.py # Admin integration │ ├── models.py # Custom model fields │ ├── forms.py # Forms with validation │ ├── views.py # Class-based views │ ├── urls.py # URL patterns │ ├── middleware.py # Request logging middleware │ ├── signals.py # Message event signals │ ├── handlers.py # Signal handlers │ ├── tasks.py # Celery tasks │ ├── serializers.py # DRF serializers │ ├── api_views.py # DRF viewsets │ └── tests/ # Test files └── manage.py ``` ## Configuration ### settings.py ```python import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent # Sent DM Configuration SENT_DM_API_KEY = os.environ.get("SENT_DM_API_KEY") SENT_DM_WEBHOOK_SECRET = os.environ.get("SENT_DM_WEBHOOK_SECRET") SENT_DM_WEBHOOK_PATH = os.environ.get("SENT_DM_WEBHOOK_PATH", "/webhooks/sent/") SENT_DM_MAX_RETRIES = int(os.environ.get("SENT_DM_MAX_RETRIES", "3")) SENT_DM_RETRY_DELAY = int(os.environ.get("SENT_DM_RETRY_DELAY", "60")) if not SENT_DM_API_KEY: raise ValueError("SENT_DM_API_KEY environment variable is required.") INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "celery", "sent_integration.apps.SentIntegrationConfig", ] REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 20, } CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") ``` ### Environment Variables ```bash SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here SENT_DM_WEBHOOK_PATH=/webhooks/sent/ SENT_DM_MAX_RETRIES=3 SENT_DM_RETRY_DELAY=60 CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://localhost:6379/0 ``` ## Environment Variables Reference | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `SENT_DM_API_KEY` | Yes | - | Your Sent DM API key | | `SENT_DM_WEBHOOK_SECRET` | No | - | Secret for webhook signature verification | | `SENT_DM_WEBHOOK_PATH` | No | `/webhooks/sent/` | URL path for webhook endpoint | | `SENT_DM_MAX_RETRIES` | No | `3` | Maximum retries for failed API calls | | `SENT_DM_RETRY_DELAY` | No | `60` | Delay in seconds between retries | | `CELERY_BROKER_URL` | No | `redis://localhost:6379/0` | Celery broker URL | | `CELERY_RESULT_BACKEND` | No | `redis://localhost:6379/0` | Celery result backend URL | ## AppConfig with Initialization ### apps.py ```python from django.apps import AppConfig from django.conf import settings from django.core.exceptions import ImproperlyConfigured class SentIntegrationConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "sent_integration" verbose_name = "Sent DM Integration" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._sent_client = None @property def sent_client(self): if self._sent_client is None: self._sent_client = self._create_client() return self._sent_client def _create_client(self): from sent_dm import SentDm api_key = getattr(settings, "SENT_DM_API_KEY", None) if not api_key: raise ImproperlyConfigured("SENT_DM_API_KEY must be set in settings") return SentDm(api_key) def ready(self): import sent_integration.handlers # noqa: F401 self._validate_configuration() def _validate_configuration(self): webhook_secret = getattr(settings, "SENT_DM_WEBHOOK_SECRET", None) if not webhook_secret: import warnings warnings.warn( "SENT_DM_WEBHOOK_SECRET is not set. Webhook verification will fail.", RuntimeWarning, stacklevel=2, ) def get_sent_client(): from django.apps import apps config = apps.get_app_config("sent_integration") return config.sent_client ``` ## Models ### models.py ```python from django.db import models from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ import re class PhoneNumberField(models.CharField): E164_REGEX = re.compile(r"^\+[1-9]\d{1,14}$") def __init__(self, *args, **kwargs): kwargs.setdefault("max_length", 20) kwargs.setdefault("help_text", _("Phone number in E.164 format (e.g., +1234567890)")) super().__init__(*args, **kwargs) def validate(self, value, model_instance): super().validate(value, model_instance) if value and not self.E164_REGEX.match(value): raise ValidationError( _("Enter a valid phone number in E.164 format (e.g., +1234567890)."), code="invalid_phone_number" ) class SentMessage(models.Model): id = models.BigAutoField(primary_key=True) sent_id = models.CharField(max_length=100, unique=True, db_index=True, verbose_name=_("Sent Message ID")) phone_number = PhoneNumberField(verbose_name=_("Phone Number")) template_id = models.CharField(max_length=100, verbose_name=_("Template ID")) template_name = models.CharField(max_length=100, verbose_name=_("Template Name")) variables = models.JSONField(default=dict, blank=True, verbose_name=_("Template Variables")) status = models.CharField(max_length=20, default="pending", verbose_name=_("Status")) channels = models.JSONField(default=list, blank=True, verbose_name=_("Channels")) error_message = models.TextField(blank=True, verbose_name=_("Error Message")) sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At")) delivered_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Delivered At")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) class Meta: verbose_name = _("Sent Message") verbose_name_plural = _("Sent Messages") ordering = ["-created_at"] indexes = [ models.Index(fields=["status", "created_at"]), models.Index(fields=["phone_number", "created_at"]), ] def __str__(self): return f"{self.template_name} to {self.phone_number} ({self.status})" def mark_as_sent(self, sent_id: str): from django.utils import timezone self.sent_id = sent_id self.status = "sent" self.sent_at = timezone.now() self.save(update_fields=["sent_id", "status", "sent_at"]) def mark_as_delivered(self): from django.utils import timezone self.status = "delivered" self.delivered_at = timezone.now() self.save(update_fields=["status", "delivered_at"]) def mark_as_failed(self, error_message: str): self.status = "failed" self.error_message = error_message self.save(update_fields=["status", "error_message"]) class MessageTemplate(models.Model): template_id = models.CharField(max_length=100, unique=True, db_index=True) name = models.CharField(max_length=100) description = models.TextField(blank=True) content = models.TextField() variables = models.JSONField(default=list) channels = models.JSONField(default=list) is_active = models.BooleanField(default=True) last_synced = models.DateTimeField(auto_now=True) class Meta: ordering = ["name"] def __str__(self): return self.name ``` ## Forms (Condensed) ### forms.py ```python import re from django import forms from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ class SendMessageForm(forms.Form): phone_number = forms.CharField(max_length=20, label=_("Phone Number")) template_id = forms.CharField(max_length=100, label=_("Template ID")) template_name = forms.CharField(max_length=100, label=_("Template Name")) variables = forms.JSONField(required=False, initial=dict, label=_("Template Variables")) channels = forms.CharField(required=False, label=_("Channels")) def clean_phone_number(self): phone = self.cleaned_data.get("phone_number", "").strip() phone = re.sub(r"\s+", "", phone) e164_pattern = re.compile(r"^\+[1-9]\d{1,14}$") if not e164_pattern.match(phone): raise ValidationError(_("Enter a valid phone number in E.164 format.")) return phone def clean_channels(self): channels_str = self.cleaned_data.get("channels", "").strip() if not channels_str: return ["whatsapp"] channels = [c.strip().lower() for c in channels_str.split(",")] valid_channels = {"whatsapp", "sms", "email", "push"} invalid = set(channels) - valid_channels if invalid: raise ValidationError(_("Invalid channels: %(channels)s"), params={"channels": ", ".join(invalid)}) return channels class WelcomeMessageForm(forms.Form): phone_number = forms.CharField(max_length=20, label=_("Phone Number")) name = forms.CharField(max_length=100, required=False, label=_("Customer Name")) def clean_phone_number(self): phone = self.cleaned_data.get("phone_number", "").strip() phone = re.sub(r"\s+", "", phone) if not re.compile(r"^\+[1-9]\d{1,14}$").match(phone): raise ValidationError(_("Enter a valid phone number in E.164 format.")) return phone # Additional forms: OrderConfirmationForm, BulkMessageForm... # See full source for complete implementations ``` ## Signals ### signals.py ```python from django.dispatch import Signal message_queued = Signal() # Sent when a message is queued message_sent = Signal() # Sent when a message is successfully sent message_delivered = Signal() # Sent when a message is delivered message_failed = Signal() # Sent when a message fails webhook_received = Signal() # Sent when a webhook is received ``` ### handlers.py ```python import logging from django.dispatch import receiver from .signals import message_sent, message_delivered, message_failed logger = logging.getLogger(__name__) @receiver(message_sent) def log_message_sent(sender, instance, response, **kwargs): logger.info("Message sent: %s (Sent ID: %s)", instance.id, instance.sent_id) @receiver(message_delivered) def log_message_delivered(sender, instance, event_data, **kwargs): logger.info("Message delivered: %s (Sent ID: %s)", instance.id, instance.sent_id) @receiver(message_failed) def handle_message_failed(sender, instance, error, **kwargs): logger.error("Message failed: %s - Error: %s", instance.id, error) # Add retry logic or alert notifications here # Additional handlers for message_queued, message_read, webhook_received... # See full source for complete implementations ``` ## Class-Based Views ### views.py ```python import json import logging from django.http import JsonResponse from django.views import View from django.views.generic import FormView, TemplateView from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.urls import reverse_lazy from django.contrib import messages from django.conf import settings from .forms import SendMessageForm, WelcomeMessageForm from .models import SentMessage from .apps import get_sent_client from .signals import message_queued, message_sent, message_delivered, message_failed logger = logging.getLogger(__name__) class SendMessageView(LoginRequiredMixin, PermissionRequiredMixin, FormView): template_name = "sent_integration/send_message.html" form_class = SendMessageForm permission_required = "sent_integration.send_message" success_url = reverse_lazy("sent_integration:message_list") def form_valid(self, form): client = get_sent_client() message_record = SentMessage.objects.create( phone_number=form.cleaned_data["phone_number"], template_id=form.cleaned_data["template_id"], template_name=form.cleaned_data["template_name"], variables=form.cleaned_data["variables"], channel=form.cleaned_data["channels"], status="pending", ) message_queued.send(sender=SentMessage, instance=message_record) try: result = client.messages.send( to=[form.cleaned_data["phone_number"]], template={ "id": form.cleaned_data["template_id"], "name": form.cleaned_data["template_name"], "parameters": form.cleaned_data["variables"] }, channel=form.cleaned_data["channels"], ) if result.success: message_record.mark_as_sent(result.data.id) message_sent.send(sender=SentMessage, instance=message_record, response=result.data) messages.success(self.request, f"Message sent! ID: {result.data.id}") else: message_record.mark_as_failed(str(result.error)) message_failed.send(sender=SentMessage, instance=message_record, error=str(result.error)) messages.error(self.request, f"Failed: {str(result.error)}") except Exception as e: logger.exception("Error sending message") message_record.mark_as_failed(str(e)) message_failed.send(sender=SentMessage, instance=message_record, error=str(e)) messages.error(self.request, f"Error: {str(e)}") return super().form_valid(form) class MessageListView(LoginRequiredMixin, TemplateView): template_name = "sent_integration/message_list.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["messages"] = SentMessage.objects.all()[:100] return context @method_decorator(csrf_exempt, name="dispatch") class WebhookView(View): http_method_names = ["post", "head", "options"] def post(self, request, *args, **kwargs): client = get_sent_client() signature = request.headers.get("X-Webhook-Signature") if not signature: return JsonResponse({"error": "Missing signature"}, status=401) payload = request.body.decode("utf-8") webhook_secret = getattr(settings, "SENT_DM_WEBHOOK_SECRET", None) if webhook_secret: is_valid = client.webhooks.verify_signature( payload=payload, signature=signature, secret=webhook_secret ) if not is_valid: return JsonResponse({"error": "Invalid signature"}, status=401) try: event = json.loads(payload) except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON"}, status=400) event_type = event.get("type") event_data = event.get("data", {}) return self.handle_event(event_type, event_data) def handle_event(self, event_type: str, event_data: dict) -> JsonResponse: handlers = { "message.status.updated": self.handle_status_update, "message.delivered": self.handle_delivered, "message.failed": self.handle_failed, } handler = handlers.get(event_type) if handler: return handler(event_data) return JsonResponse({"received": True, "handled": False}) def handle_delivered(self, data: dict) -> JsonResponse: message_id = data.get("id") try: message = SentMessage.objects.get(sent_id=message_id) message.mark_as_delivered() message_delivered.send(sender=SentMessage, instance=message, event_data=data) except SentMessage.DoesNotExist: logger.warning(f"Message not found: {message_id}") return JsonResponse({"received": True}) def handle_failed(self, data: dict) -> JsonResponse: message_id = data.get("id") error_message = data.get("error", {}).get("message", "Unknown error") try: message = SentMessage.objects.get(sent_id=message_id) message.mark_as_failed(error_message) message_failed.send(sender=SentMessage, instance=message, error=error_message) except SentMessage.DoesNotExist: logger.warning(f"Message not found: {message_id}") return JsonResponse({"received": True}) def handle_status_update(self, data: dict) -> JsonResponse: # Handle status updates - implementation similar to above return JsonResponse({"received": True}) # Additional views: SendWelcomeMessageView, MessageDetailView, APIStatusView... # See full source for complete implementations ``` ## Middleware ### middleware.py ```python import time import logging import uuid from typing import Callable from django.http import HttpRequest, HttpResponse from django.conf import settings logger = logging.getLogger("sent_dm") class SentRequestLoggingMiddleware: def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: if not self._is_sent_request(request): return self.get_response(request) correlation_id = str(uuid.uuid4())[:8] request.sent_correlation_id = correlation_id start_time = time.time() try: response = self.get_response(request) duration = (time.time() - start_time) * 1000 logger.info("[Sent %s] Response: %d - Duration: %.2fms", correlation_id, response.status_code, duration) response["X-Correlation-ID"] = correlation_id return response except Exception as e: duration = (time.time() - start_time) * 1000 logger.error("[Sent %s] Request failed after %.2fms: %s", correlation_id, duration, str(e), exc_info=True) raise def _is_sent_request(self, request: HttpRequest) -> bool: webhook_path = getattr(settings, "SENT_DM_WEBHOOK_PATH", "/webhooks/sent/") if request.path.startswith(webhook_path): return True resolver = getattr(request, "resolver_match", None) if resolver and resolver.namespace == "sent_integration": return True return False # Additional middleware: SentWebhookSecurityMiddleware... # See full source for complete implementations ``` ## Admin Configuration ### admin.py ```python from django.contrib import admin from django.utils.html import format_html from .models import SentMessage, MessageTemplate @admin.register(SentMessage) class SentMessageAdmin(admin.ModelAdmin): list_display = ["id", "phone_number", "template_name", "status_badge", "sent_at", "created_at"] list_filter = ["status", "template_name", "channels", "created_at"] search_fields = ["phone_number", "sent_id", "template_id", "template_name"] readonly_fields = ["sent_id", "sent_at", "delivered_at", "created_at", "updated_at"] date_hierarchy = "created_at" ordering = ["-created_at"] actions = ["mark_as_failed", "retry_message"] def status_badge(self, obj: SentMessage) -> str: colors = { "pending": "orange", "queued": "blue", "sent": "purple", "delivered": "green", "read": "teal", "failed": "red", "cancelled": "gray", } color = colors.get(obj.status, "gray") return format_html('{}', color, obj.status.upper()) status_badge.short_description = "Status" @admin.action(description="Mark selected messages as failed") def mark_as_failed(self, request, queryset): updated = queryset.update(status="failed") self.message_user(request, f"{updated} messages marked as failed.") @admin.action(description="Retry selected failed messages") def retry_message(self, request, queryset): from .tasks import send_message_task count = 0 for message in queryset.filter(status="failed"): send_message_task.delay( phone_number=message.phone_number, template_id=message.template_id, template_name=message.template_name, variables=message.variables, channels=message.channels, ) count += 1 self.message_user(request, f"Queued {count} messages for retry.") @admin.register(MessageTemplate) class MessageTemplateAdmin(admin.ModelAdmin): list_display = ["name", "template_id", "is_active", "last_synced"] list_filter = ["is_active", "channels"] search_fields = ["name", "template_id", "description"] readonly_fields = ["last_synced"] ``` ## Celery Tasks ### tasks.py ```python import logging from celery import shared_task from celery.exceptions import MaxRetriesExceededError from django.conf import settings from sent_dm.exceptions import RateLimitError, APIError from .apps import get_sent_client from .models import SentMessage from .signals import message_sent, message_failed logger = logging.getLogger(__name__) @shared_task(bind=True, max_retries=3, default_retry_delay=60, autoretry_for=(RateLimitError,)) def send_message_task(self, phone_number: str, template_id: str, template_name: str = None, variables: dict = None, channel: list = None, message_record_id: int = None): client = get_sent_client() message_record = None if message_record_id: try: message_record = SentMessage.objects.get(id=message_record_id) except SentMessage.DoesNotExist: logger.warning(f"Message record {message_record_id} not found") try: result = client.messages.send( to=[phone_number], template={ "id": template_id, "name": template_name or template_id, "parameters": variables or {} }, channel=channel or ["whatsapp"], ) if result.success: if message_record: message_record.mark_as_sent(result.data.id) message_sent.send(sender=SentMessage, instance=message_record, response=result.data) return {"success": True, "message_id": result.data.id} else: error_msg = str(result.error) if result.error else "Unknown error" if message_record: message_record.mark_as_failed(error_msg) message_failed.send(sender=SentMessage, instance=message_record, error=error_msg) raise APIError(error_msg) except RateLimitError as exc: retry_delay = 60 * (2 ** self.request.retries) raise self.retry(exc=exc, countdown=retry_delay) @shared_task def send_bulk_messages_task(phone_numbers: list[str], template_id: str, template_name: str = None, variables: dict = None, channel: list = None): from .tasks import send_message_task task_ids = [] for phone_number in phone_numbers: message_record = SentMessage.objects.create( phone_number=phone_number, template_id=template_id, template_name=template_name or template_id, variables=variables or {}, channel=channel or ["whatsapp"], status="pending", ) task = send_message_task.delay( phone_number=phone_number, template_id=template_id, template_name=template_name, variables=variables, channel=channel, message_record_id=message_record.id ) task_ids.append(task.id) return {"queued": len(task_ids), "task_ids": task_ids} # Additional tasks: process_webhook_event_task, retry_failed_messages_task... # See full source for complete implementations ``` ## Django REST Framework Integration ### serializers.py ```python import re from rest_framework import serializers from .models import SentMessage, MessageTemplate class SendMessageSerializer(serializers.Serializer): phone_number = serializers.CharField(max_length=20) template_id = serializers.CharField(max_length=100) template_name = serializers.CharField(max_length=100, required=False) variables = serializers.DictField(required=False, default=dict) channels = serializers.ListField(child=serializers.CharField(), required=False, default=list) async_send = serializers.BooleanField(required=False, default=False) def validate_phone_number(self, value: str) -> str: phone = re.sub(r"\s+", "", value.strip()) if not re.compile(r"^\+[1-9]\d{1,14}$").match(phone): raise serializers.ValidationError("Enter a valid phone number in E.164 format.") return phone class SentMessageSerializer(serializers.ModelSerializer): class Meta: model = SentMessage fields = ["id", "sent_id", "phone_number", "template_id", "template_name", "variables", "status", "channels", "error_message", "sent_at", "delivered_at", "created_at", "updated_at"] read_only_fields = ["id", "sent_id", "status", "error_message", "sent_at", "delivered_at", "created_at", "updated_at"] # Additional serializers: MessageTemplateSerializer, BulkMessageSerializer... # See full source for complete implementations ``` ### api_views.py (Key Parts) ```python from rest_framework import viewsets, status from rest_framework.decorators import action, api_view, permission_classes from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from .models import SentMessage, MessageTemplate from .apps import get_sent_client from .tasks import send_message_task, send_bulk_messages_task from .serializers import SendMessageSerializer, SentMessageSerializer, MessageTemplateSerializer class MessageViewSet(viewsets.ModelViewSet): queryset = SentMessage.objects.all() serializer_class = SentMessageSerializer permission_classes = [IsAuthenticated] def get_queryset(self): queryset = SentMessage.objects.all() status_filter = self.request.query_params.get("status") if status_filter: queryset = queryset.filter(status=status_filter) return queryset.order_by("-created_at") @action(detail=False, methods=["post"], url_path="send") def send_message(self, request): serializer = SendMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data message_record = SentMessage.objects.create( phone_number=data["phone_number"], template_id=data["template_id"], template_name=data.get("template_name", data["template_id"]), variables=data.get("variables", {}), channel=data.get("channels", ["whatsapp"]), status="pending", ) if data.get("async_send", False): task = send_message_task.delay( phone_number=data["phone_number"], template_id=data["template_id"], template_name=data.get("template_name"), variables=data.get("variables"), channel=data.get("channels"), message_record_id=message_record.id ) return Response({"success": True, "task_id": task.id, "status": "queued"}, status=status.HTTP_202_ACCEPTED) # Synchronous sending client = get_sent_client() result = client.messages.send( to=[data["phone_number"]], template={ "id": data["template_id"], "name": data.get("template_name", data["template_id"]), "parameters": data.get("variables", {}) }, channel=data.get("channels", ["whatsapp"]) ) if result.success: message_record.mark_as_sent(result.data.id) return Response({"success": True, "message_id": result.data.id}) else: error_msg = result.error.message if result.error else "Unknown error" message_record.mark_as_failed(error_msg) return Response({"success": False, "error": error_msg}, status=status.HTTP_400_BAD_REQUEST) # Additional viewsets: TemplateViewSet, webhook_api, api_status... # See full source for complete implementations ``` ## Testing (Condensed) ### Test Configuration ```python # myproject/settings_test.py from .settings import * DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } SENT_DM_API_KEY = "your_api_key_here" SENT_DM_WEBHOOK_SECRET = "your_webhook_secret_here" CELERY_TASK_ALWAYS_EAGER = True PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] ``` ### Example Tests ```python # sent_integration/tests/test_forms.py from django.test import TestCase from sent_integration.forms import SendMessageForm class SendMessageFormTest(TestCase): def test_valid_form(self): data = { "phone_number": "+1234567890", "template_id": "welcome-template", "template_name": "welcome", "variables": {"name": "John"}, "channels": "whatsapp, sms", } form = SendMessageForm(data) self.assertTrue(form.is_valid()) def test_invalid_phone_number(self): data = {"phone_number": "1234567890", "template_id": "welcome", "template_name": "welcome"} form = SendMessageForm(data) self.assertFalse(form.is_valid()) self.assertIn("phone_number", form.errors) # Additional test cases... # See full source for complete implementations ``` ```python # sent_integration/tests/test_models.py from django.test import TestCase from sent_integration.models import SentMessage class SentMessageModelTest(TestCase): def test_create_message(self): message = SentMessage.objects.create( sent_id="msg_123", phone_number="+1234567890", template_id="welcome", template_name="welcome", status="pending" ) self.assertEqual(str(message), "welcome to +1234567890 (pending)") def test_mark_as_sent(self): message = SentMessage.objects.create( phone_number="+1234567890", template_id="welcome", template_name="welcome" ) message.mark_as_sent("sent_msg_123") self.assertEqual(message.sent_id, "sent_msg_123") self.assertEqual(message.status, "sent") # Additional test cases: test_mark_as_delivered, test_mark_as_failed... # See full source for complete implementations ``` ```python # sent_integration/tests/test_views.py import json from unittest.mock import Mock, patch from django.test import TestCase, RequestFactory from sent_integration.models import SentMessage from sent_integration.views import WebhookView class WebhookViewTest(TestCase): def setUp(self): self.factory = RequestFactory() self.view = WebhookView() @patch("sent_integration.views.get_sent_client") def test_webhook_signature_verification(self, mock_get_client): mock_client = Mock() mock_client.webhooks.verify_signature.return_value = True mock_get_client.return_value = mock_client payload = json.dumps({"type": "message.delivered", "data": {"id": "msg_123"}}) request = self.factory.post("/webhooks/sent/", data=payload, content_type="application/json") request.headers = {"X-Webhook-Signature": "valid_signature"} with self.settings(SENT_DM_WEBHOOK_SECRET="secret"): response = self.view.post(request) self.assertEqual(response.status_code, 200) # Additional test cases: test_views, test_forms... # See full source for complete implementations ``` ## URL Patterns ### sent_integration/urls.py ```python from django.urls import path from . import views app_name = "sent_integration" urlpatterns = [ path("messages/", views.MessageListView.as_view(), name="message_list"), path("messages//", views.MessageDetailView.as_view(), name="message_detail"), path("messages/send/", views.SendMessageView.as_view(), name="send_message"), path("messages/send/welcome/", views.SendWelcomeMessageView.as_view(), name="send_welcome"), path("webhook/", views.WebhookView.as_view(), name="webhook"), path("api/status/", views.APIStatusView.as_view(), name="api_status"), ] ``` ### Project urls.py ```python from django.contrib import admin from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), path("sent/", include("sent_integration.urls", namespace="sent_integration")), path("webhooks/sent/", include("sent_integration.urls")), ] ``` ## Running Tests ```bash # Run all tests python manage.py test sent_integration # Run specific test file python manage.py test sent_integration.tests.test_forms # Run with coverage pip install coverage coverage run --source=sent_integration manage.py test sent_integration coverage report ``` ## Management Commands ```bash # Sync all templates from Sent python manage.py sync_templates # Sync specific template python manage.py sync_templates --template-id=template_123 # Dry run python manage.py sync_templates --dry-run # Send test message python manage.py send_test_message +1234567890 --template-id=welcome --var=name=John ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Python SDK reference](/sdks/python) for advanced features - See [Celery Integration](/sdks/python/integrations/celery) for background tasks ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/fastapi.txt TITLE: FastAPI Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/fastapi.txt Production-ready FastAPI integration with dependency injection, Pydantic v2, and async patterns # FastAPI Integration Complete FastAPI integration with Pydantic v2 models, dependency injection, lifespan context management, and production-ready patterns. This guide uses modern FastAPI patterns including `lifespan` context managers, Pydantic v2 Settings, and modular APIRouter architecture. ## Project Structure ``` my_fastapi_app/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI app factory │ ├── config.py # Pydantic Settings │ ├── dependencies.py # Shared dependencies │ ├── lifespan.py # Lifespan context manager │ ├── middleware.py # Custom middleware │ ├── exceptions.py # Custom exceptions & handlers │ ├── routers/ │ │ ├── __init__.py │ │ ├── messages.py # Message endpoints │ │ ├── webhooks.py # Webhook handlers │ │ └── health.py # Health checks │ ├── services/ │ │ ├── __init__.py │ │ └── sent_service.py # Sent client service │ ├── models/ │ │ ├── __init__.py │ │ └── schemas.py # Pydantic models │ └── utils/ │ ├── __init__.py │ └── logging.py # Logging configuration ├── tests/ │ ├── __init__.py │ ├── conftest.py # Pytest fixtures │ ├── test_messages.py │ └── test_webhooks.py ├── .env ├── pyproject.toml └── requirements.txt ``` ## Installation ```bash pip install sentdm fastapi uvicorn pydantic-settings slowapi # For improved async HTTP performance pip install sentdm[aiohttp] # Development dependencies pip install pytest pytest-asyncio httpx ``` ## Configuration ```python # app/config.py from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import Field, field_validator from functools import lru_cache class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) app_name: str = Field(default="Sent DM API", alias="APP_NAME") app_version: str = Field(default="1.0.0", alias="APP_VERSION") debug: bool = Field(default=False, alias="DEBUG") sent_dm_api_key: str = Field(alias="SENT_DM_API_KEY") sent_dm_webhook_secret: str | None = Field(default=None, alias="SENT_DM_WEBHOOK_SECRET") sent_dm_base_url: str | None = Field(default=None, alias="SENT_DM_BASE_URL") rate_limit: str = Field(default="100/minute", alias="RATE_LIMIT") log_level: str = Field(default="INFO", alias="LOG_LEVEL") @field_validator("sent_dm_api_key") @classmethod def validate_api_key(cls, v: str) -> str: if not v or len(v) < 10: raise ValueError("SENT_DM_API_KEY must be a valid API key") return v @lru_cache def get_settings() -> Settings: return Settings() ``` ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret DEBUG=false LOG_LEVEL=INFO RATE_LIMIT=100/minute ``` ## Async Client Manager with Lifespan ```python # app/lifespan.py from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI from sent_dm import AsyncSentDm from app.config import get_settings class SentClientManager: def __init__(self) -> None: self._client: AsyncSentDm | None = None async def initialize(self) -> None: settings = get_settings() self._client = AsyncSentDm( api_key=settings.sent_dm_api_key, base_url=settings.sent_dm_base_url, ) await self._client.__aenter__() async def cleanup(self) -> None: if self._client: await self._client.__aexit__(None, None, None) self._client = None @property def client(self) -> AsyncSentDm: if self._client is None: raise RuntimeError("Sent client not initialized") return self._client sent_manager = SentClientManager() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await sent_manager.initialize() yield await sent_manager.cleanup() ``` ## Schemas ```python # app/models/schemas.py from pydantic import BaseModel, Field, field_validator from typing import Literal, Any from datetime import datetime class TemplateRequest(BaseModel): id: str = Field(..., min_length=1) name: str = Field(..., min_length=1) parameters: dict[str, str] = Field(default_factory=dict) class SendMessageRequest(BaseModel): phone_numbers: list[str] = Field(..., min_length=1, max_length=100) template: TemplateRequest channels: list[Literal["sms", "whatsapp", "telegram"]] = Field(default=["whatsapp"]) test_mode: bool = Field(default=False) @field_validator("phone_numbers") @classmethod def validate_phone_numbers(cls, v: list[str]) -> list[str]: for phone in v: if not phone.startswith("+"): raise ValueError(f"Phone number '{phone}' must start with '+' (E.164 format)") return v class SendMessageResponse(BaseModel): message_id: str status: str recipient: str sent_at: datetime = Field(default_factory=datetime.utcnow) class BatchSendResponse(BaseModel): total_requested: int successful: int failed: int messages: list[SendMessageResponse] errors: list[dict[str, Any]] = Field(default_factory=list) class WebhookEvent(BaseModel): type: str data: dict[str, Any] timestamp: datetime | None = Field(default=None) class HealthCheckResponse(BaseModel): status: Literal["healthy", "unhealthy"] version: str timestamp: datetime services: dict[str, Literal["up", "down"]] ``` ## Dependencies ```python # app/dependencies.py from fastapi import Request, Depends, Header, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sent_dm import AsyncSentDm from app.config import Settings, get_settings from app.lifespan import sent_manager security = HTTPBearer(auto_error=False) def get_sent_client() -> AsyncSentDm: return sent_manager.client async def verify_webhook_signature( request: Request, x_webhook_signature: str | None = Header(None), settings: Settings = Depends(get_settings), ) -> bytes: if not x_webhook_signature: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing X-Webhook-Signature header", ) if not settings.sent_dm_webhook_secret: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Webhook secret not configured", ) payload = await request.body() client = sent_manager.client is_valid = client.webhooks.verify_signature( payload=payload.decode(), signature=x_webhook_signature, secret=settings.sent_dm_webhook_secret, ) if not is_valid: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid webhook signature", ) return payload async def get_optional_token( credentials: HTTPAuthorizationCredentials | None = Depends(security), ) -> str | None: return credentials.credentials if credentials else None ``` ## Exceptions ```python # app/exceptions.py from fastapi import Request, FastAPI from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from sent_dm import APIError import logging logger = logging.getLogger(__name__) class SentServiceException(Exception): def __init__(self, message: str, status_code: int = 500): self.message = message self.status_code = status_code super().__init__(self.message) async def sent_api_exception_handler(request: Request, exc: APIError) -> JSONResponse: logger.error( "Sent API Error", extra={"status": exc.status, "error": exc.name, "path": request.url.path}, ) status_code = {400: 400, 401: 401, 403: 403, 404: 404, 422: 422, 429: 429}.get(exc.status, 500) return JSONResponse( status_code=status_code, content={"error": exc.name, "message": exc.message, "status_code": status_code}, ) async def validation_exception_handler( request: Request, exc: RequestValidationError ) -> JSONResponse: errors = [ {"field": ".".join(str(x) for x in error["loc"]), "message": error["msg"], "type": error["type"]} for error in exc.errors() ] return JSONResponse( status_code=422, content={"error": "Validation Error", "message": "Request validation failed", "details": errors}, ) async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content={"error": "HTTP Error", "message": exc.detail, "status_code": exc.status_code}, ) def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(APIError, sent_api_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(StarletteHTTPException, http_exception_handler) ``` ## Middleware ```python # app/middleware.py import time import logging import uuid from typing import Callable from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware logger = logging.getLogger(__name__) class RequestIdMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable) -> Response: request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-ID"] = request_id return response class LoggingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable) -> Response: start_time = time.time() request_id = getattr(request.state, "request_id", "unknown") logger.info( "Request started", extra={"request_id": request_id, "method": request.method, "path": request.url.path}, ) try: response = await call_next(request) except Exception as exc: logger.error("Request failed", extra={"request_id": request_id, "error": str(exc)}) raise process_time = time.time() - start_time response.headers["X-Process-Time"] = str(process_time) logger.info( "Request completed", extra={ "request_id": request_id, "method": request.method, "path": request.url.path, "status_code": response.status_code, "duration_ms": round(process_time * 1000, 2), }, ) return response ``` ## Service Layer ```python # app/services/sent_service.py import logging from typing import Any from sent_dm import AsyncSentDm from app.models.schemas import SendMessageRequest, SendMessageResponse, BatchSendResponse logger = logging.getLogger(__name__) class SentService: def __init__(self, client: AsyncSentDm): self._client = client async def send_message(self, request: SendMessageRequest) -> SendMessageResponse: response = await self._client.messages.send( to=request.phone_numbers, template={ "id": request.template.id, "name": request.template.name, "parameters": request.template.parameters, }, channel=request.channels, test_mode=request.test_mode, ) message = response.data.messages[0] logger.info( "Message sent", extra={"message_id": message.id, "recipient": request.phone_numbers[0], "status": message.status}, ) return SendMessageResponse( message_id=message.id, status=message.status, recipient=request.phone_numbers[0], ) async def send_batch(self, requests: list[SendMessageRequest]) -> BatchSendResponse: messages = [] errors = [] successful = 0 for req in requests: try: result = await self.send_message(req) messages.append(result) successful += 1 except Exception as e: errors.append({"recipient": req.phone_numbers[0] if req.phone_numbers else None, "error": str(e)}) return BatchSendResponse( total_requested=len(requests), successful=successful, failed=len(errors), messages=messages, errors=errors, ) ``` ## Routers ### Messages Router ```python # app/routers/messages.py from fastapi import APIRouter, Depends, status from slowapi import Limiter from slowapi.util import get_remote_address from app.dependencies import get_sent_client from app.models.schemas import SendMessageRequest, SendMessageResponse, BatchSendResponse from app.services.sent_service import SentService from app.config import get_settings limiter = Limiter(key_func=get_remote_address) router = APIRouter( prefix="/api/messages", tags=["Messages"], responses={401: {"description": "Unauthorized"}, 429: {"description": "Rate limit exceeded"}}, ) @router.post("/send", response_model=SendMessageResponse, status_code=status.HTTP_200_OK) @limiter.limit(lambda: get_settings().rate_limit) async def send_message( request: SendMessageRequest, client=Depends(get_sent_client), ) -> SendMessageResponse: service = SentService(client) return await service.send_message(request) @router.post("/send-batch", response_model=BatchSendResponse, status_code=status.HTTP_200_OK) @limiter.limit(lambda: get_settings().rate_limit) async def send_batch( requests: list[SendMessageRequest], client=Depends(get_sent_client), ) -> BatchSendResponse: service = SentService(client) return await service.send_batch(requests) ``` ### Webhooks Router ```python # app/routers/webhooks.py import json import logging from typing import Any from fastapi import APIRouter, Depends, BackgroundTasks, status from app.dependencies import verify_webhook_signature logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) async def process_webhook_event(event_data: dict[str, Any]) -> None: event_type = event_data.get("type") data = event_data.get("data", {}) logger.info(f"Processing webhook event: {event_type}") match event_type: case "message.status.updated": logger.info(f"Message {data.get('id')} status updated to {data.get('status')}") case "message.delivered": logger.info(f"Message {data.get('id')} delivered successfully") case "message.failed": logger.error(f"Message {data.get('id')} failed: {data.get('error', {}).get('message')}") case "message.read": logger.info(f"Message {data.get('id')} was read by recipient") case _: logger.warning(f"Unhandled webhook event type: {event_type}") @router.post("/sent", status_code=status.HTTP_200_OK) async def handle_webhook( background_tasks: BackgroundTasks, payload: bytes = Depends(verify_webhook_signature), ) -> dict[str, bool]: event_data = json.loads(payload) background_tasks.add_task(process_webhook_event, event_data) return {"received": True} ``` ### Health Router ```python # app/routers/health.py from datetime import datetime from fastapi import APIRouter, Depends, status from sent_dm import AsyncSentDm from app.dependencies import get_sent_client from app.config import get_settings from app.models.schemas import HealthCheckResponse router = APIRouter(prefix="/health", tags=["Health"]) @router.get("", response_model=HealthCheckResponse) async def health_check(client: AsyncSentDm = Depends(get_sent_client)) -> HealthCheckResponse: settings = get_settings() services: dict[str, str] = {"api": "up", "sent_dm": "up"} return HealthCheckResponse( status="healthy", # type: ignore version=settings.app_version, timestamp=datetime.utcnow(), services=services, # type: ignore ) @router.get("/ready", status_code=status.HTTP_200_OK) async def readiness() -> dict[str, str]: return {"status": "ready"} @router.get("/live", status_code=status.HTTP_200_OK) async def liveness() -> dict[str, str]: return {"status": "alive"} ``` ## Main Application ```python # app/main.py import logging from fastapi import FastAPI from slowapi import Limiter from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from app.config import get_settings from app.lifespan import lifespan from app.middleware import RequestIdMiddleware, LoggingMiddleware from app.exceptions import register_exception_handlers from app.routers import messages, webhooks, health logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) def create_application() -> FastAPI: settings = get_settings() app = FastAPI( title=settings.app_name, version=settings.app_version, description="FastAPI integration with Sent DM for messaging", lifespan=lifespan, docs_url="/docs" if settings.debug else None, redoc_url="/redoc" if settings.debug else None, ) app.add_middleware(RequestIdMiddleware) app.add_middleware(LoggingMiddleware) limiter = Limiter(key_func=lambda: "global") app.state.limiter = limiter app.add_middleware(SlowAPIMiddleware) register_exception_handlers(app) app.include_router(health.router) app.include_router(messages.router) app.include_router(webhooks.router) return app app = create_application() if __name__ == "__main__": import uvicorn uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=get_settings().debug) ``` ```python # app/routers/__init__.py from app.routers import messages, webhooks, health __all__ = ["messages", "webhooks", "health"] ``` ## Testing ```python # tests/conftest.py import pytest import pytest_asyncio from fastapi.testclient import TestClient from httpx import AsyncClient, ASGITransport from app.main import create_application from app.config import Settings, get_settings def get_test_settings() -> Settings: return Settings( sent_dm_api_key="your_api_key_here", sent_dm_webhook_secret="your_webhook_secret_here", debug=True, log_level="DEBUG", ) @pytest.fixture def app(): app = create_application() app.dependency_overrides[get_settings] = get_test_settings return app @pytest.fixture def client(app) -> TestClient: return TestClient(app) @pytest_asyncio.fixture async def async_client(app) -> AsyncClient: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client ``` ```python # tests/test_messages.py import pytest from unittest.mock import AsyncMock, patch from fastapi import status from sent_dm import APIError @pytest.mark.asyncio async def test_send_message_success(async_client): mock_response = {"data": {"messages": [{"id": "msg_123", "status": "pending"}]}} with patch("sent_dm.AsyncSentDm.messages.send", new_callable=AsyncMock) as mock_send: mock_send.return_value = mock_response response = await async_client.post( "/api/messages/send", json={ "phone_numbers": ["+1234567890"], "template": {"id": "template-123", "name": "welcome", "parameters": {"name": "John"}}, "channels": ["whatsapp"], }, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["message_id"] == "msg_123" @pytest.mark.asyncio async def test_send_message_validation_error(async_client): response = await async_client.post("/api/messages/send", json={"phone_numbers": ["invalid"]}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_send_message_api_error(async_client): with patch("sent_dm.AsyncSentDm.messages.send", new_callable=AsyncMock) as mock_send: mock_send.side_effect = APIError(status=400, name="BadRequestError", message="Invalid phone number", headers={}) response = await async_client.post( "/api/messages/send", json={"phone_numbers": ["+1234567890"], "template": {"id": "template-123", "name": "welcome"}}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio async def test_health_check(async_client): response = await async_client.get("/health") assert response.status_code == status.HTTP_200_OK assert "services" in response.json() ``` ```python # tests/test_webhooks.py import json import hmac import hashlib import pytest from unittest.mock import patch from fastapi import status def generate_signature(payload: str, secret: str) -> str: return hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() @pytest.mark.asyncio async def test_webhook_success(async_client): payload = {"type": "message.delivered", "data": {"id": "msg_123", "status": "delivered"}} with patch("sent_dm.AsyncSentDm.webhooks.verify_signature") as mock_verify: mock_verify.return_value = True response = await async_client.post( "/webhooks/sent", content=json.dumps(payload), headers={"X-Webhook-Signature": "valid_signature"}, ) assert response.status_code == status.HTTP_200_OK @pytest.mark.asyncio async def test_webhook_missing_signature(async_client): response = await async_client.post("/webhooks/sent", json={"type": "test"}) assert response.status_code == status.HTTP_401_UNAUTHORIZED ``` ## Running the Application ```bash # Development uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 # Production (using Gunicorn with Uvicorn workers) gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 # With Docker FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY app/ ./app/ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `SENT_DM_API_KEY` | Yes | Your Sent DM API key | | `SENT_DM_WEBHOOK_SECRET` | No | Secret for webhook signature verification | | `SENT_DM_BASE_URL` | No | Custom API base URL (for testing) | | `DEBUG` | No | Enable debug mode (default: false) | | `LOG_LEVEL` | No | Logging level (default: INFO) | | `RATE_LIMIT` | No | Rate limit string (default: 100/minute) | ## Next Steps - [Handle errors](/sdks/python) in your application - [Configure webhooks](/start/webhooks/getting-started) to receive delivery status - Learn about [best practices](/sdks/best-practices) for production deployments - Explore [Pydantic v2 documentation](https://docs.pydantic.dev/) for advanced validation - Check [FastAPI documentation](https://fastapi.tiangolo.com/) for more patterns ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/python/integrations/flask.txt TITLE: Flask Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/python/integrations/flask.txt Production-ready Flask integration with blueprints, factories, and best practices # Flask Integration Complete Flask 3.0 integration with Application Factory pattern, Blueprints, validation, security headers, rate limiting, and comprehensive testing. This example uses Flask 3.0+ patterns including Application Factory, Blueprints, and modern extension patterns. ## Project Structure ``` myapp/ ├── app/ │ ├── __init__.py # Application factory │ ├── extensions.py # Flask extensions │ ├── config.py # Environment configurations │ ├── logging_config.py # Logging setup │ ├── sent_integration.py # Sent Flask extension │ ├── blueprints/ │ │ ├── __init__.py │ │ ├── messages.py # Message routes │ │ └── webhooks.py # Webhook handlers │ ├── schemas/ │ │ ├── __init__.py │ │ └── message_schemas.py # Marshmallow schemas │ ├── services/ │ │ ├── __init__.py │ │ └── message_service.py # Business logic │ └── utils/ │ ├── __init__.py │ └── decorators.py # Custom decorators ├── tests/ │ ├── __init__.py │ ├── conftest.py # Pytest fixtures │ ├── test_messages.py │ └── test_webhooks.py ├── wsgi.py # WSGI entry point ├── gunicorn.conf.py # Gunicorn configuration ├── requirements.txt └── .env ``` ## Installation ```bash pip install flask sentdm marshmallow flask-marshmallow flask-limiter flask-talisman pip install pydantic python-dotenv gunicorn pytest pytest-flask ``` ## Configuration ```python # app/config.py import os from datetime import timedelta class Config: SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key') SENT_DM_API_KEY = os.getenv('SENT_DM_API_KEY') SENT_DM_WEBHOOK_SECRET = os.getenv('SENT_DM_WEBHOOK_SECRET') RATELIMIT_STORAGE_URI = os.getenv('RATELIMIT_STORAGE_URI', 'memory://') RATELIMIT_STRATEGY = 'fixed-window' RATELIMIT_DEFAULT = '100/minute' SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' PERMANENT_SESSION_LIFETIME = timedelta(hours=24) LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') class DevelopmentConfig(Config): DEBUG = True SESSION_COOKIE_SECURE = False LOG_LEVEL = 'DEBUG' class TestingConfig(Config): TESTING = True DEBUG = True SENT_DM_API_KEY = 'test-api-key' SENT_DM_WEBHOOK_SECRET = 'test-webhook-secret' RATELIMIT_ENABLED = False SESSION_COOKIE_SECURE = False class ProductionConfig(Config): DEBUG = False LOG_LEVEL = 'WARNING' config = { 'development': DevelopmentConfig, 'testing': TestingConfig, 'production': ProductionConfig, 'default': DevelopmentConfig } ``` ## Logging Configuration ```python # app/logging_config.py import logging import sys from logging.handlers import RotatingFileHandler def configure_logging(app): log_level = getattr(logging, app.config['LOG_LEVEL'], logging.INFO) app.logger.setLevel(log_level) app.logger.handlers.clear() console = logging.StreamHandler(sys.stdout) console.setFormatter(logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - %(message)s' )) app.logger.addHandler(console) if not app.debug and not app.testing: file_handler = RotatingFileHandler('app.log', maxBytes=10485760, backupCount=10) file_handler.setLevel(logging.WARNING) app.logger.addHandler(file_handler) ``` ## Flask Extension for Sent ```python # app/sent_integration.py from flask import current_app, g from sent_dm import SentDm class SentExtension: def __init__(self, app=None): self.app = app if app is not None: self.init_app(app) def init_app(self, app): app.extensions = getattr(app, 'extensions', {}) app.extensions['sent'] = self app.teardown_appcontext(self.teardown) def teardown(self, exception): pass @property def client(self): if '_sent_client' not in g: api_key = current_app.config.get('SENT_DM_API_KEY') if not api_key: raise RuntimeError("SENT_DM_API_KEY not configured") g._sent_client = SentDm(api_key) return g._sent_client sent_extension = SentExtension() def get_sent_client(): return current_app.extensions['sent'].client ``` ## Extensions Setup ```python # app/extensions.py from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_talisman import Talisman from flask_marshmallow import Marshmallow limiter = Limiter(key_func=get_remote_address, default_limits=["100 per minute"]) talisman = Talisman() ma = Marshmallow() ``` ## Marshmallow Schemas ```python # app/schemas/message_schemas.py from marshmallow import Schema, fields, validate class SendMessageSchema(Schema): phone_number = fields.String( required=True, validate=validate.Regexp(r'^\+[1-9]\d{1,14}$', error="E.164 format required") ) template_id = fields.String(required=True, validate=validate.Length(min=1, max=100)) template_name = fields.String(required=True, validate=validate.Length(min=1, max=100)) parameters = fields.Dict(keys=fields.String(), values=fields.String(), load_default=dict) channels = fields.List( fields.String(validate=validate.OneOf(['sms', 'whatsapp', 'email'])), load_default=list ) class WelcomeMessageSchema(Schema): phone_number = fields.String(required=True, validate=validate.Regexp(r'^\+[1-9]\d{1,14}$')) name = fields.String(load_default="Valued Customer", validate=validate.Length(max=100)) class OrderConfirmationSchema(Schema): phone_number = fields.String(required=True, validate=validate.Regexp(r'^\+[1-9]\d{1,14}$')) order_number = fields.String(required=True) total = fields.String(required=True) class MessageResponseSchema(Schema): message_id = fields.String(dump_only=True) status = fields.String(dump_only=True) sent_at = fields.DateTime(dump_only=True) class WebhookEventSchema(Schema): type = fields.String(required=True) data = fields.Dict(required=True) timestamp = fields.DateTime(dump_only=True) send_message_schema = SendMessageSchema() welcome_message_schema = WelcomeMessageSchema() order_confirmation_schema = OrderConfirmationSchema() message_response_schema = MessageResponseSchema() webhook_event_schema = WebhookEventSchema() ``` ## Business Logic Service ```python # app/services/message_service.py import logging from flask import current_app from app.sent_integration import get_sent_client logger = logging.getLogger(__name__) class MessageService: def __init__(self): self.sent = None def _get_client(self): if self.sent is None: self.sent = get_sent_client() return self.sent def send_message(self, phone_number: str, template_id: str, template_name: str, parameters: dict = None, channels: list = None) -> dict: try: client = self._get_client() response = client.messages.send( to=[phone_number], template={'id': template_id, 'name': template_name, 'parameters': parameters or {}}, channel=channels ) message = response.data.messages[0] logger.info(f"Message sent: {message.id} to {phone_number}") return {'message_id': message.id, 'status': message.status, 'success': True} except Exception as e: logger.error(f"Failed to send message: {str(e)}") raise def send_welcome_message(self, phone_number: str, name: str = "Valued Customer") -> dict: return self.send_message( phone_number=phone_number, template_id='welcome-template-id', template_name='welcome', parameters={'name': name}, channel=['whatsapp'] ) def send_order_confirmation(self, phone_number: str, order_number: str, total: str) -> dict: return self.send_message( phone_number=phone_number, template_id='order-confirmation-id', template_name='order_confirmation', parameters={'order_number': order_number, 'total': total}, channel=['sms', 'whatsapp'] ) message_service = MessageService() ``` ## Custom Decorators ```python # app/utils/decorators.py import functools import hmac import hashlib from flask import request, jsonify, current_app def verify_webhook_signature(f): @functools.wraps(f) def decorated_function(*args, **kwargs): signature = request.headers.get('X-Webhook-Signature') if not signature: return jsonify({'error': 'Missing webhook signature'}), 401 webhook_secret = current_app.config.get('SENT_DM_WEBHOOK_SECRET') if not webhook_secret: return jsonify({'error': 'Webhook secret not configured'}), 500 expected = hmac.new( webhook_secret.encode('utf-8'), request.get_data(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(signature, expected): return jsonify({'error': 'Invalid signature'}), 401 return f(*args, **kwargs) return decorated_function def handle_validation_errors(f): @functools.wraps(f) def decorated_function(*args, **kwargs): from marshmallow import ValidationError try: return f(*args, **kwargs) except ValidationError as err: return jsonify({'error': 'Validation error', 'messages': err.messages}), 400 return decorated_function ``` ## Blueprints ### Messages Blueprint ```python # app/blueprints/messages.py from flask import Blueprint, request, jsonify, current_app from marshmallow import ValidationError from app.extensions import limiter, ma from app.schemas.message_schemas import ( send_message_schema, welcome_message_schema, order_confirmation_schema, message_response_schema ) from app.services.message_service import message_service from app.utils.decorators import handle_validation_errors messages_bp = Blueprint('messages', __name__, url_prefix='/api/messages') @messages_bp.errorhandler(ValidationError) def handle_validation_error(error): return jsonify({'error': 'Validation error', 'messages': error.messages}), 400 @messages_bp.errorhandler(Exception) def handle_generic_error(error): current_app.logger.error(f"Error: {str(error)}", exc_info=True) return jsonify({'error': 'Internal server error'}), 500 @messages_bp.route('/send', methods=['POST']) @limiter.limit("10 per minute") @handle_validation_errors def send_message(): json_data = request.get_json() if not json_data: return jsonify({'error': 'No input data provided'}), 400 data = send_message_schema.load(json_data) result = message_service.send_message( phone_number=data['phone_number'], template_id=data['template_id'], template_name=data['template_name'], parameters=data.get('parameters'), channels=data.get('channels') ) return jsonify(message_response_schema.dump(result)), 200 @messages_bp.route('/welcome', methods=['POST']) @limiter.limit("10 per minute") @handle_validation_errors def send_welcome(): data = welcome_message_schema.load(request.get_json() or {}) result = message_service.send_welcome_message( phone_number=data['phone_number'], name=data.get('name', 'Valued Customer') ) return jsonify(message_response_schema.dump(result)), 200 @messages_bp.route('/order-confirmation', methods=['POST']) @limiter.limit("10 per minute") @handle_validation_errors def send_order_confirmation(): data = order_confirmation_schema.load(request.get_json() or {}) result = message_service.send_order_confirmation( phone_number=data['phone_number'], order_number=data['order_number'], total=data['total'] ) return jsonify(message_response_schema.dump(result)), 200 ``` ### Webhooks Blueprint ```python # app/blueprints/webhooks.py import logging from flask import Blueprint, request, jsonify, current_app from app.extensions import limiter from app.utils.decorators import verify_webhook_signature from app.schemas.message_schemas import webhook_event_schema logger = logging.getLogger(__name__) webhooks_bp = Blueprint('webhooks', __name__, url_prefix='/webhooks') @webhooks_bp.route('/sent', methods=['POST']) @limiter.limit("100 per minute") @verify_webhook_signature def handle_sent_webhook(): event_data = request.get_json() if not event_data: return jsonify({'error': 'Invalid JSON payload'}), 400 try: event = webhook_event_schema.load(event_data) process_webhook_event(event) return jsonify({'received': True}), 200 except Exception as e: logger.error(f"Error processing webhook: {str(e)}") return jsonify({'received': True}), 200 def process_webhook_event(event: dict): event_type = event.get('type') data = event.get('data', {}) logger.info(f"Processing webhook: {event_type}") match event_type: case 'message.status.updated': logger.info(f"Message {data.get('id')} status: {data.get('status')}") case 'message.delivered': logger.info(f"Message {data.get('id')} delivered") case 'message.failed': logger.error(f"Message {data.get('id')} failed: {data.get('error', {})}") case 'message.read': logger.info(f"Message {data.get('id')} read") case _: logger.warning(f"Unhandled event: {event_type}") ``` ## Application Factory ```python # app/__init__.py from flask import Flask, jsonify from app.config import config from app.extensions import limiter, talisman, ma, sent_extension from app.logging_config import configure_logging from app.blueprints.messages import messages_bp from app.blueprints.webhooks import webhooks_bp def create_app(config_name='default'): app = Flask(__name__) app.config.from_object(config[config_name]) config[config_name].init_app(app) configure_logging(app) limiter.init_app(app) ma.init_app(app) sent_extension.init_app(app) talisman.init_app(app, force_https=False, strict_transport_security=True, content_security_policy={'default-src': "'self'", 'script-src': "'self'"}, referrer_policy='strict-origin-when-cross-origin') app.register_blueprint(messages_bp) app.register_blueprint(webhooks_bp) register_error_handlers(app) @app.route('/health') def health_check(): return jsonify({'status': 'healthy', 'service': 'sent-flask-integration'}) @app.route('/') def index(): return jsonify({ 'service': 'Sent DM Flask Integration', 'endpoints': {'health': '/health', 'messages': '/api/messages', 'webhooks': '/webhooks/sent'} }) return app def register_error_handlers(app): @app.errorhandler(400) def bad_request(error): return jsonify({'error': 'Bad request'}), 400 @app.errorhandler(429) def rate_limit_handler(error): return jsonify({'error': 'Rate limit exceeded', 'retry_after': error.description}), 429 @app.errorhandler(500) def internal_error(error): app.logger.error(f"Server error: {str(error)}", exc_info=True) return jsonify({'error': 'Internal server error'}), 500 ``` ## WSGI Entry Point ```python # wsgi.py import os from app import create_app config_name = os.getenv('FLASK_CONFIG', 'production') app = create_app(config_name) if __name__ == '__main__': app.run() ``` ## Gunicorn Configuration ```python # gunicorn.conf.py import multiprocessing import os bind = os.getenv('GUNICORN_BIND', '0.0.0.0:8000') workers = multiprocessing.cpu_count() * 2 + 1 worker_class = 'sync' worker_connections = 1000 timeout = 30 keepalive = 2 accesslog = '-' errorlog = '-' loglevel = os.getenv('GUNICORN_LOG_LEVEL', 'info') preload_app = True keyfile = os.getenv('SSL_KEY_FILE') certfile = os.getenv('SSL_CERT_FILE') ``` ## Testing ### Pytest Configuration ```python # tests/conftest.py import pytest from app import create_app from app.extensions import limiter @pytest.fixture def app(): app = create_app('testing') limiter.enabled = False with app.app_context(): yield app @pytest.fixture def client(app): return app.test_client() @pytest.fixture def auth_headers(): return {'Content-Type': 'application/json'} @pytest.fixture def mock_sent_client(mocker): mock = mocker.MagicMock() mock.messages.send.return_value = mocker.MagicMock( data=mocker.MagicMock(messages=[mocker.MagicMock(id='msg_123', status='pending')]) ) return mock ``` ### Message Tests ```python # tests/test_messages.py import json import pytest from unittest.mock import patch class TestSendMessage: def test_send_message_success(self, client, auth_headers, mocker): mock_response = mocker.MagicMock() mock_response.data.messages = [mocker.MagicMock(id='msg_123', status='pending')] with patch('app.services.message_service.get_sent_client') as mock_get_client: mock_client = mocker.MagicMock() mock_client.messages.send.return_value = mock_response mock_get_client.return_value = mock_client response = client.post('/api/messages/send', data=json.dumps({ 'phone_number': '+1234567890', 'template_id': 'template-123', 'template_name': 'welcome', 'parameters': {'name': 'John'} }), headers=auth_headers) assert response.status_code == 200 data = json.loads(response.data) assert data['message_id'] == 'msg_123' def test_send_message_validation_error(self, client, auth_headers): response = client.post('/api/messages/send', data=json.dumps({ 'phone_number': 'invalid', 'template_id': 'template-123', 'template_name': 'welcome' }), headers=auth_headers) assert response.status_code == 400 def test_send_message_missing_fields(self, client, auth_headers): response = client.post('/api/messages/send', data=json.dumps({}), headers=auth_headers) assert response.status_code == 400 class TestWelcomeMessage: def test_send_welcome_success(self, client, auth_headers, mocker): mock_response = mocker.MagicMock() mock_response.data.messages = [mocker.MagicMock(id='msg_456', status='queued')] with patch('app.services.message_service.get_sent_client') as mock_get_client: mock_client = mocker.MagicMock() mock_client.messages.send.return_value = mock_response mock_get_client.return_value = mock_client response = client.post('/api/messages/welcome', data=json.dumps({ 'phone_number': '+1234567890', 'name': 'Jane' }), headers=auth_headers) assert response.status_code == 200 call_args = mock_client.messages.send.call_args assert call_args[1]['template']['name'] == 'welcome' ``` ### Webhook Tests ```python # tests/test_webhooks.py import json import hmac import hashlib import pytest from unittest.mock import patch class TestWebhookHandler: def _generate_signature(self, payload: str, secret: str) -> str: return hmac.new(secret.encode('utf-8'), payload.encode('utf-8'), hashlib.sha256).hexdigest() def test_webhook_missing_signature(self, client): response = client.post('/webhooks/sent', data='{}') assert response.status_code == 401 def test_webhook_invalid_signature(self, client): response = client.post('/webhooks/sent', data='{}', headers={'X-Webhook-Signature': 'invalid'}) assert response.status_code == 401 def test_webhook_valid_signature(self, client, app): payload = json.dumps({'type': 'message.delivered', 'data': {'id': 'msg_123'}}) signature = self._generate_signature(payload, app.config['SENT_DM_WEBHOOK_SECRET']) response = client.post('/webhooks/sent', data=payload, headers={'X-Webhook-Signature': signature}) assert response.status_code == 200 def test_webhook_message_failed(self, client, app): payload = json.dumps({'type': 'message.failed', 'data': {'id': 'msg_123', 'error': {'message': 'Invalid'}}}) signature = self._generate_signature(payload, app.config['SENT_DM_WEBHOOK_SECRET']) with patch('app.blueprints.webhooks.logger') as mock_logger: response = client.post('/webhooks/sent', data=payload, headers={'X-Webhook-Signature': signature}) assert response.status_code == 200 mock_logger.error.assert_called_once() ``` ## Running the Application ### Development ```bash export FLASK_APP=wsgi.py export FLASK_CONFIG=development export SENT_DM_API_KEY=your_api_key export SENT_DM_WEBHOOK_SECRET=your_webhook_secret flask run ``` ### Production ```bash gunicorn -c gunicorn.conf.py wsgi:app ``` ### Docker ```dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"] ``` ## Environment Variables ```bash # Required SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret # Optional FLASK_CONFIG=production SECRET_KEY=your-secret-key LOG_LEVEL=INFO RATELIMIT_STORAGE_URI=redis://localhost:6379 SSL_KEY_FILE=/path/to/key.pem SSL_CERT_FILE=/path/to/cert.pem ``` ## Rate Limiting | Endpoint | Limit | |----------|-------| | `/api/messages/*` | 10 requests/minute | | `/webhooks/sent` | 100 requests/minute | | Other endpoints | 100 requests/minute (default) | Configure Redis for distributed rate limiting: ```bash RATELIMIT_STORAGE_URI=redis://redis:6379/0 ``` ## Security Headers Flask-Talisman adds: `Strict-Transport-Security`, `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Python SDK reference](/sdks/python) for advanced features - Review [Celery integration](/sdks/python/integrations/celery) for background processing ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/ruby.txt TITLE: Ruby SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/ruby.txt Official Ruby SDK for Sent. Elegant DSL for Rails applications with comprehensive type support. # Ruby SDK The official Ruby SDK for Sent LogoSent provides an elegant, Ruby-idiomatic interface for sending messages. Built with love for Rails applications, featuring comprehensive types & docstrings in Yard, RBS, and RBI. ## Requirements Ruby 3.2.0 or later. ## Installation To use this gem, install via Bundler by adding the following to your application's `Gemfile`: ```ruby gem "sentdm", "~> 0.13.0" ``` Then run: ```bash bundle install ``` Or install directly: ```bash gem install sentdm ``` ## Quick Start ### Initialize the client ```ruby require "sentdm" sent_dm = Sentdm::Client.new( api_key: ENV["SENT_DM_API_KEY"] # This is the default and can be omitted ) ``` ### Send your first message ```ruby require "sentdm" sent_dm = Sentdm::Client.new result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: { name: "John Doe", order_id: "12345" } }, channel: ["sms", "whatsapp"] ) puts(result.data.messages[0].id) puts(result.data.messages[0].status) ``` ## Authentication The client reads `SENT_DM_API_KEY` from the environment by default, or you can pass it explicitly: ```ruby require "sentdm" # Using environment variables sent_dm = Sentdm::Client.new # Or explicit configuration sent_dm = Sentdm::Client.new( api_key: "your_api_key" ) ``` ## Send Messages The Ruby SDK uses `send_` (with trailing underscore) instead of `send` because `send` is a reserved method in Ruby's Object class. ### Send a message ```ruby result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: { name: "John Doe", order_id: "12345" } }, channel: ["sms", "whatsapp"] ) puts(result.data.messages[0].id) puts(result.data.messages[0].status) ``` ### Test mode Use `test_mode: true` to validate requests without sending real messages: ```ruby result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, test_mode: true # Validates but doesn't send ) # Response will have test data puts(result.data.messages[0].id) puts(result.data.messages[0].status) ``` ## Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `Sentdm::Errors::APIError` will be thrown: ```ruby begin result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: {name: "John Doe", order_id: "12345"} } ) rescue Sentdm::Errors::APIConnectionError => e puts("The server could not be reached") puts(e.cause) # an underlying Exception, likely raised within `net/http` rescue Sentdm::Errors::RateLimitError => e puts("A 429 status code was received; we should back off a bit.") rescue Sentdm::Errors::APIStatusError => e puts("Another non-200-range status code was received") puts(e.status) end ``` Error codes are as follows: | Cause | Error Type | |-------|------------| | HTTP 400 | `BadRequestError` | | HTTP 401 | `AuthenticationError` | | HTTP 403 | `PermissionDeniedError` | | HTTP 404 | `NotFoundError` | | HTTP 409 | `ConflictError` | | HTTP 422 | `UnprocessableEntityError` | | HTTP 429 | `RateLimitError` | | HTTP >= 500 | `InternalServerError` | | Other HTTP error | `APIStatusError` | | Timeout | `APITimeoutError` | | Network error | `APIConnectionError` | ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. ```ruby # Configure the default for all requests: sent_dm = Sentdm::Client.new( max_retries: 0 # default is 2 ) # Or, configure per-request: sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, request_options: {max_retries: 5} ) ``` ## Timeouts By default, requests will time out after 60 seconds. ```ruby # Configure the default for all requests: sent_dm = Sentdm::Client.new( timeout: nil # default is 60 ) # Or, configure per-request: sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, request_options: {timeout: 5} ) ``` ## BaseModel All parameter and response objects inherit from `Sentdm::Internal::Type::BaseModel`, which provides several conveniences: 1. All fields, including unknown ones, are accessible with `obj[:prop]` syntax 2. Structural equivalence for equality 3. Both instances and classes can be pretty-printed 4. Helpers such as `#to_h`, `#deep_to_h`, `#to_json`, and `#to_yaml` ```ruby result = sent_dm.templates.list # Access fields template = result.data.data.first template.name template[:name] # Same thing # Convert to hash template.to_h # Serialize to JSON template.to_json # Pretty print puts template.inspect ``` ## Contacts Create and manage contacts: ```ruby # Create a contact result = sent_dm.contacts.create( phone_number: "+1234567890" ) puts "Contact ID: #{result.data.id}" puts "Channels: #{result.data.available_channels}" # List contacts result = sent_dm.contacts.list(limit: 100) result.data.data.each do |contact| puts "#{contact.phone_number} - #{contact.available_channels}" end # Get a contact result = sent_dm.contacts.get("contact-uuid") # Update a contact result = sent_dm.contacts.update( "contact-uuid", phone_number: "+1987654321" ) # Delete a contact sent_dm.contacts.delete("contact-uuid") ``` ## Templates List and retrieve templates: ```ruby # List templates result = sent_dm.templates.list result.data.data.each do |template| puts "#{template.name} (#{template.status}): #{template.id}" puts " Category: #{template.category}" puts " Channels: #{template.channels.join(', ')}" end # Get a specific template result = sent_dm.templates.get("template-uuid") puts "Name: #{result.data.name}" puts "Status: #{result.data.status}" ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. The signed content is `{X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}` and the signature format is `v1,{base64(hmac)}`. See the Rails and Sinatra integration sections below for full examples, and the [Webhooks reference](/reference/api/webhooks) for the full payload schema. ## Making custom or undocumented requests ### Undocumented properties You can send undocumented parameters to any endpoint using `extra_*` options: ```ruby result = sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome" }, request_options: { extra_query: {my_query_parameter: value}, extra_body: {my_body_parameter: value}, extra_headers: {"my-header" => value} } ) puts(result[:my_undocumented_property]) ``` ### Undocumented endpoints To make requests to undocumented endpoints: ```ruby response = sent_dm.request( method: :post, path: '/undocumented/endpoint', query: {"dog" => "woof"}, headers: {"useful-header" => "interesting-value"}, body: {"hello" => "world"} ) ``` ## Concurrency & connection pooling The `Sentdm::Client` instances are thread-safe, but are only fork-safe when there are no in-flight HTTP requests. Each instance of `Sentdm::Client` has its own HTTP connection pool with a default size of 99. As such, we recommend instantiating the client once per application in most settings. When all available connections from the pool are checked out, requests wait for a new connection to become available, with queue time counting towards the request timeout. ## Rails Integration ### Configuration ```ruby # config/initializers/sentdm.rb $SENT_DM = Sentdm::Client.new( api_key: ENV['SENT_DM_API_KEY'] ) # Usage anywhere $SENT_DM.messages.send_( to: ['+1234567890'], template: { id: 'welcome-template', name: 'welcome' } ) ``` ### Controllers ```ruby # app/controllers/messages_controller.rb class MessagesController < ApplicationController skip_before_action :verify_authenticity_token, only: [:webhook] def send_welcome result = $SENT_DM.messages.send_( to: [params[:phone]], template: { id: 'welcome-template', name: 'welcome', parameters: {name: params[:name]} } ) if result.success render json: { success: true, message_id: result.data.messages[0].id, status: result.data.messages[0].status } else render json: {error: result.error.message}, status: :bad_request end end def webhook require 'openssl' require 'base64' # 1. Read raw body payload = request.body.read webhook_id = request.headers['X-Webhook-ID'] || '' timestamp = request.headers['X-Webhook-Timestamp'] || '' signature = request.headers['X-Webhook-Signature'] || '' # 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" secret = ENV['SENT_DM_WEBHOOK_SECRET'] # "whsec_abc123..." key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_')) signed = "#{webhook_id}.#{timestamp}.#{payload}" digest = OpenSSL::HMAC.digest('SHA256', key_bytes, signed) expected = "v1,#{Base64.strict_encode64(digest)}" unless ActiveSupport::SecurityUtils.secure_compare(expected, signature) return render json: {error: 'Invalid signature'}, status: :unauthorized end # 3. Optional: reject replayed events older than 5 minutes if (Time.now.to_i - timestamp.to_i).abs > 300 return render json: {error: 'Timestamp too old'}, status: :unauthorized end event = JSON.parse(payload, symbolize_names: true) # 4. Update message status in your own database if event[:field] == 'messages' Message.where(sent_id: event[:payload][:message_id]) .update_all(status: event[:payload][:message_status]) end # 5. Always return 200 quickly render json: {received: true} end end ``` ### ActiveJob Integration ```ruby # app/jobs/send_welcome_message_job.rb class SendWelcomeMessageJob < ApplicationJob queue_as :default retry_on Sentdm::Errors::RateLimitError, wait: :polynomially_longer, attempts: 5 discard_on Sentdm::Errors::ValidationError def perform(user) result = $SENT_DM.messages.send_( to: [user.phone_number], template: { id: 'welcome-template', name: 'welcome', parameters: {name: user.first_name} } ) raise result.error.message unless result.success end end ``` ### Rake Tasks ```ruby # lib/tasks/sentdm.rake namespace :sentdm do desc "Send test message" task :test, [:phone, :template] => :environment do |t, args| args.with_defaults(template: 'welcome-template') puts "Sending test message to #{args.phone}..." result = $SENT_DM.messages.send_( to: [args.phone], template: { id: args.template, name: 'welcome' } ) if result.success puts "Sent: #{result.data.messages[0].id}" else puts "Failed: #{result.error.message}" exit 1 end end end ``` ## Sinatra Integration ```ruby # app.rb require 'sinatra' require 'sentdm' require 'json' configure do set :sent_client, Sentdm::Client.new end post '/send-message' do content_type :json data = JSON.parse(request.body.read) result = settings.sent_client.messages.send_( to: [data['phone_number']], template: { id: data['template_id'], name: data['template_name'] || 'welcome', parameters: data['variables'] || {} } ) if result.success {message_id: result.data.messages[0].id, status: result.data.messages[0].status}.to_json else status 400 {error: result.error.message}.to_json end end post '/webhooks/sent' do require 'openssl' require 'base64' content_type :json # 1. Read raw body payload = request.body.read webhook_id = request.env['HTTP_X_WEBHOOK_ID'] || '' timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP'] || '' signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || '' # 2. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" secret = ENV['SENT_DM_WEBHOOK_SECRET'] # "whsec_abc123..." key_bytes = Base64.strict_decode64(secret.delete_prefix('whsec_')) signed = "#{webhook_id}.#{timestamp}.#{payload}" digest = OpenSSL::HMAC.digest('SHA256', key_bytes, signed) expected = "v1,#{Base64.strict_encode64(digest)}" unless Rack::Utils.secure_compare(expected, signature) status 401 return {error: 'Invalid signature'}.to_json end # 3. Optional: reject replayed events older than 5 minutes if (Time.now.to_i - timestamp.to_i).abs > 300 status 401 return {error: 'Timestamp too old'}.to_json end event = JSON.parse(payload, symbolize_names: true) # 4. Update message status in your own database if event[:field] == 'messages' # DB[:messages].where(sent_id: event[:payload][:message_id]) # .update(status: event[:payload][:message_status]) puts "Message #{event[:payload][:message_id]} → #{event[:payload][:message_status]}" end # 5. Always return 200 quickly {received: true}.to_json end ``` ## Sorbet This library provides comprehensive [RBI](https://sorbet.org/start/rbi) definitions and has no dependency on sorbet-runtime. You can provide typesafe request parameters: ```ruby sent_dm.messages.send_( to: ["+1234567890"], template: { id: "7ba7b820-9dad-11d1-80b4-00c04fd430c8", name: "welcome", parameters: {name: "John Doe"} } ) ``` ## Source & Issues - **Version**: 0.13.0 - **GitHub**: [sentdm/sent-dm-ruby](https://github.com/sentdm/sent-dm-ruby) - **RubyGems**: [sentdm](https://rubygems.org/gems/sentdm) - **RubyDoc**: [gemdocs.org/gems/sentdm](https://gemdocs.org/gems/sentdm) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-ruby/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/ruby/integrations/rails.txt TITLE: Ruby on Rails Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/ruby/integrations/rails.txt Complete Rails integration with Service Objects, ActiveJob, webhooks, and comprehensive testing # Ruby on Rails Integration Complete integration guide for Ruby on Rails applications using modern patterns including Service Objects, Form Objects, Concerns, and comprehensive error handling. This example follows Rails 7/8 best practices including Zeitwerk autoloading, ActiveJob with Sidekiq, and service-oriented architecture patterns. ## Project Structure ``` app/ ├── controllers/api/v1/ │ ├── messages_controller.rb │ └── webhooks_controller.rb ├── services/sent_dm/ │ ├── base_service.rb │ ├── send_message_service.rb │ └── send_welcome_service.rb ├── concerns/ │ ├── models/sent_dm_messageable.rb │ └── controllers/webhook_verifiable.rb ├── jobs/sent_dm/ │ ├── send_message_job.rb │ └── process_webhook_job.rb └── models/message.rb ``` ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `SENT_DM_API_KEY` | Yes | Your Sent DM API key for authentication | | `SENT_DM_WEBHOOK_SECRET` | Yes (production) | Secret for verifying webhook signatures | | `SENT_BASE_URL` | No | API base URL (default: `https://api.sent.dm`) | | `SENT_TIMEOUT` | No | Request timeout in seconds (default: `30`) | | `SENT_RETRY_ATTEMPTS` | No | Number of retry attempts for failed requests (default: `3`) | | `REDIS_URL` | No | Redis connection URL for Sidekiq (default: `redis://localhost:6379/0`) | ### Example `.env` file ```bash # Required SENT_DM_API_KEY=your_api_key_here # Required for production SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here # Optional SENT_BASE_URL=https://api.sent.dm SENT_TIMEOUT=30 SENT_RETRY_ATTEMPTS=3 REDIS_URL=redis://localhost:6379/0 ``` ## Configuration ### Initializer ```ruby # config/initializers/sentdm.rb module SentDmConfig class ConfigurationError < StandardError; end class << self def configure validate_environment! SentDM.configure do |config| config.api_key = api_key config.webhook_secret = webhook_secret config.base_url = base_url config.timeout = timeout config.retry_attempts = retry_attempts end end def client @client ||= Sentdm::Client.new(api_key) end private def validate_environment! raise ConfigurationError, 'SENT_DM_API_KEY required' if api_key.blank? raise ConfigurationError, 'SENT_DM_WEBHOOK_SECRET required in production' if webhook_secret.blank? && Rails.env.production? end def api_key = ENV.fetch('SENT_DM_API_KEY', nil) def webhook_secret = ENV.fetch('SENT_DM_WEBHOOK_SECRET', nil) def base_url = ENV.fetch('SENT_BASE_URL', 'https://api.sent.dm') def timeout = ENV.fetch('SENT_TIMEOUT', '30').to_i def retry_attempts = ENV.fetch('SENT_RETRY_ATTEMPTS', '3').to_i end end SentDmConfig.configure ``` ## Base Classes ```ruby # app/services/application_service.rb class ApplicationService include ActiveModel::Validations class ServiceError < StandardError; end class ValidationError < ServiceError; end def self.call(...) = new(...).call def call = raise NotImplementedError def success(data = nil) = ServiceResult.success(data) def failure(errors, data = nil) = ServiceResult.failure(errors, data) def validate! = raise(ValidationError, errors.full_messages.join(', ')) unless valid? end class ServiceResult attr_reader :data, :errors def initialize(success:, data: nil, errors: []) = (@success, @data, @errors = success, data, Array(errors)) def self.success(data = nil) = new(success: true, data: data) def self.failure(errors, data = nil) = new(success: false, errors: errors, data: data) def success? = @success def failure? = !success? end ``` ## Service Objects ### Base Service ```ruby # app/services/sent_dm/base_service.rb module SentDm class BaseService < ApplicationService protected def client = @client ||= SentDmConfig.client def handle_api_error(error) Rails.logger.error "[SentDM] API Error: #{error.message}" case error when SentDM::RateLimitError then failure(:rate_limited, "Rate limit exceeded") when SentDM::AuthenticationError then failure(:unauthorized, "Authentication failed") when SentDM::ValidationError then failure(:validation_error, error.message) else failure(:api_error, "Unexpected error") end end def with_transaction ActiveRecord::Base.transaction { yield } rescue ActiveRecord::RecordInvalid => e failure(:database_error, e.message) end end end ``` ### Send Message Service ```ruby # app/services/sent_dm/send_message_service.rb module SentDm class SendMessageService < BaseService attr_reader :phone_number, :template_id, :template_name, :variables, :channels, :user validates :phone_number, presence: true, format: { with: /\A\+[1-9]\d{1,14}\z/, message: 'must be E.164 format' } validates :template_id, :channels, presence: true def initialize(phone_number:, template_id:, template_name: nil, variables: {}, channels: ['whatsapp'], user: nil) @phone_number, @template_id, @template_name = phone_number, template_id, template_name @variables, @channels, @user = variables, Array(channels), user end def call validate! with_transaction do message = create_message_record! response = send_to_api if response.success update_message_record!(message, response.data) success(message) else mark_failed!(message, response.error) failure(:api_error, response.error.message) end end rescue SentDM::Error => e handle_api_error(e) end private def create_message_record! Message.create!(user: user, phone_number: phone_number, template_id: template_id, template_name: template_name, variables: variables, channels: channels, status: :pending) end def send_to_api client.messages.send_(to: [phone_number], template: { id: template_id, name: template_name, parameters: variables }, channels: channels) end def update_message_record!(message, data) message.update!(external_id: data.id, status: data.status || :queued, sent_at: Time.current) end def mark_failed!(message, error) message.update!(status: :failed, error_message: error.message, failed_at: Time.current) end end end ``` ### Send Welcome Service ```ruby # app/services/sent_dm/send_welcome_service.rb module SentDm class SendWelcomeService < BaseService WELCOME_TEMPLATE_ID = 'welcome-template'.freeze DEFAULT_CHANNELS = ['whatsapp'].freeze def initialize(user) = @user = user def call return failure(:invalid_user, 'User must have phone number') if @user.phone_number.blank? SendMessageService.call(phone_number: @user.phone_number, template_id: WELCOME_TEMPLATE_ID, template_name: 'welcome', variables: { name: @user.first_name }, channels: DEFAULT_CHANNELS, user: @user) end end end ``` ## Concerns ### Webhook Verifiable ```ruby # app/controllers/concerns/webhook_verifiable.rb module WebhookVerifiable extend ActiveSupport::Concern class SignatureVerificationError < StandardError; end included do skip_before_action :verify_authenticity_token, only: [:webhook] before_action :verify_webhook_signature, only: [:webhook] rescue_from SignatureVerificationError, with: :handle_invalid_signature end private def verify_webhook_signature return if Rails.env.test? || Rails.env.development? signature = request.headers['X-Webhook-Signature'] payload = request.body.read; request.body.rewind raise SignatureVerificationError, 'Missing signature' if signature.blank? raise SignatureVerificationError, 'Invalid signature' unless secure_compare(signature, generate_signature(payload)) end def generate_signature(payload) secret = ENV.fetch('SENT_DM_WEBHOOK_SECRET') Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', secret, payload)) end def secure_compare(a, b) = ActiveSupport::SecurityUtils.secure_compare(a.to_s, b.to_s) def handle_invalid_signature = render json: { error: 'Unauthorized' }, status: :unauthorized end ``` ### Model Concern ```ruby # app/models/concerns/sent_dm_messageable.rb module SentDmMessageable extend ActiveSupport::Concern included do has_many :sent_messages, class_name: 'Message', dependent: :nullify after_create :send_welcome_message, if: :should_send_welcome? end def send_message(template_id:, variables: {}, channels: ['whatsapp']) return false unless phone_number.present? SentDm::SendMessageService.call(phone_number: phone_number, template_id: template_id, variables: variables.merge(name: respond_to?(:first_name) ? first_name : name), channels: channels, user: self) end def send_welcome_message = SentDm::SendWelcomeService.call(self) private def should_send_welcome? respond_to?(:phone_number) && phone_number.present? && respond_to?(:welcome_sent_at) && welcome_sent_at.nil? end end ``` ## Controllers ```ruby # app/controllers/api/v1/messages_controller.rb module Api module V1 class MessagesController < ApplicationController def create result = SentDm::SendMessageService.call(message_params.merge(user: current_user)) result.success? ? render(json: { success: true, data: result.data }) : render(json: { success: false, error: result.errors }, status: :unprocessable_entity) end def welcome user = User.find(params[:user_id]) result = SentDm::SendWelcomeService.call(user) result.success? ? render(json: { success: true }) : render(json: { success: false, error: result.errors }, status: :unprocessable_entity) rescue ActiveRecord::RecordNotFound render json: { error: 'User not found' }, status: :not_found end private def message_params params.require(:message).permit(:phone_number, :template_id, :template_name, :user_id, variables: {}, channels: []) end end end end # app/controllers/api/v1/webhooks_controller.rb module Api module V1 class WebhooksController < ApplicationController include WebhookVerifiable skip_before_action :verify_authenticity_token def create payload = request.body.read; request.body.rewind signature = request.headers['X-Webhook-Signature'] event = JSON.parse(payload) result = SentDm::WebhookHandlerService.call(event_type: event['type'], event_data: event['data']) result.success? ? render(json: { received: true }) : render(json: { error: result.errors }, status: :unprocessable_entity) rescue JSON::ParserError => e render json: { error: 'Invalid JSON' }, status: :bad_request end end end end ``` ## ActiveJob Integration ```ruby # app/jobs/sent_dm/send_message_job.rb module SentDm class SendMessageJob < ApplicationJob queue_as :messages sidekiq_options retry: 5 retry_on SentDM::RateLimitError, wait: :exponentially_longer, attempts: 3 discard_on SentDM::AuthenticationError do |job, error| Rails.logger.error "[SentDM] Auth failed, discarding job #{job.job_id}" end def perform(user_id, template_id, variables = {}, channels = ['whatsapp']) user = User.find(user_id) result = SendMessageService.call(phone_number: user.phone_number, template_id: template_id, variables: variables, channels: channels, user: user) raise MessageDeliveryError, result.errors.join(', ') if result.failure? result.data rescue ActiveRecord::RecordNotFound => e Rails.logger.error "[SentDM] User #{user_id} not found: #{e.message}" raise end end end # app/jobs/sent_dm/process_webhook_job.rb module SentDm class ProcessWebhookJob < ApplicationJob queue_as :webhooks sidekiq_options retry: 3 def perform(event_type, event_data) result = WebhookHandlerService.call(event_type: event_type, event_data: OpenStruct.new(event_data)) Rails.logger.info "[SentDM Webhook] Processed #{event_type}: #{result.data&.dig(:message)}" rescue StandardError => e Rails.logger.error "[SentDM Webhook] Failed #{event_type}: #{e.message}" raise end end end ``` ## Models ```ruby # app/models/message.rb class Message < ApplicationRecord belongs_to :user, optional: true enum status: { pending: 'pending', queued: 'queued', sent: 'sent', delivered: 'delivered', read: 'read', failed: 'failed' } validates :phone_number, presence: true, format: { with: /\A\+[1-9]\d{1,14}\z/ } validates :template_id, presence: true validates :external_id, uniqueness: true, allow_nil: true scope :recent, -> { order(created_at: :desc) } scope :pending, -> { where(status: [:pending, :queued]) } scope :failed, -> { where(status: :failed) } def retry! return false unless failed? update!(status: :pending, error_message: nil, failed_at: nil) SentDm::SendMessageJob.perform_later(user_id, template_id, variables, channels) end end # app/models/user.rb class User < ApplicationRecord include SentDmMessageable validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }, allow_blank: true end ``` ## Routes ```ruby # config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :messages, only: [:index, :show, :create] do collection { post :welcome } end resources :webhooks, only: [:create] end end post '/webhooks/sent', to: 'api/v1/webhooks#create' end ``` ## Testing with RSpec ### Spec Helper ```ruby # spec/rails_helper.rb require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' require_relative '../config/environment' require 'rspec/rails' require 'factory_bot_rails' require 'sidekiq/testing' Sidekiq::Testing.fake! RSpec.configure do |config| config.include FactoryBot::Syntax::Methods config.use_transactional_fixtures = true config.before(:each) { Sidekiq::Worker.clear_all } end ``` ### Factories ```ruby # spec/factories.rb FactoryBot.define do factory :user do sequence(:email) { |n| "user#{n}@example.com" } first_name { 'John' } phone_number { '+1234567890' } end factory :message do user phone_number { '+1234567890' } template_id { 'welcome-template' } status { :pending } channels { ['whatsapp'] } end end ``` ### Service Specs ```ruby # spec/services/sent_dm/send_message_service_spec.rb RSpec.describe SentDm::SendMessageService do let(:user) { create(:user) } let(:valid_params) do { phone_number: '+1234567890', template_id: 'welcome-template', channels: ['whatsapp'], user: user } end context 'with valid parameters' do let(:mock_response) { double('response', success: true, data: double(id: 'msg_123', status: 'queued')) } before { allow(SentDmConfig.client).to receive(:messages).and_return(double(send_: mock_response)) } it 'creates a message record' do expect { described_class.call(valid_params) }.to change(Message, :count).by(1) end it 'returns a successful result' do result = described_class.call(valid_params) expect(result).to be_success expect(result.data.external_id).to eq('msg_123') end end context 'with invalid phone number' do it 'returns a failure result' do result = described_class.call(valid_params.merge(phone_number: 'invalid')) expect(result).to be_failure end end context 'when API returns an error' do before { allow(SentDmConfig.client).to receive(:messages).and_raise(SentDM::RateLimitError.new('Rate limited')) } it 'returns a rate limited error' do result = described_class.call(valid_params) expect(result).to be_failure expect(result.errors).to include(:rate_limited) end end end ``` ### Job Specs ```ruby # spec/jobs/sent_dm/send_message_job_spec.rb RSpec.describe SentDm::SendMessageJob, type: :job do let(:user) { create(:user) } describe '#perform' do it 'calls SendMessageService' do expect(SentDm::SendMessageService).to receive(:call).and_return(ServiceResult.success) described_class.perform_now(user.id, 'welcome-template') end it 'raises error on service failure' do allow(SentDm::SendMessageService).to receive(:call).and_return(ServiceResult.failure([:api_error])) expect { described_class.perform_now(user.id, 'welcome-template') }.to raise_error(MessageDeliveryError) end end end ``` ## Sidekiq Configuration ```ruby # config/initializers/sidekiq.rb Sidekiq.configure_server do |config| config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } config.error_handlers << proc { |ex, ctx| Rails.logger.error "[Sidekiq] #{ex.message}" } end Sidekiq.configure_client { |config| config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } } ``` ```yaml # config/sidekiq.yml :concurrency: 5 :max_retries: 5 :queues: - [critical, 10] - [webhooks, 5] - [messages, 3] - [mailers, 2] - [default, 1] ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [Ruby SDK reference](/sdks/ruby) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/ruby/integrations/sinatra.txt TITLE: Sinatra Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/ruby/integrations/sinatra.txt Production-ready Sinatra 4.0 integration with modular architecture # Sinatra Integration Complete Sinatra 4.0 integration with modular architecture, middleware stack, and production-ready patterns. This example uses Sinatra 4.0 with modular style, dry-rb validation, and Rack middleware for production deployments. ## Project Structure ``` sent-sinatra-app/ ├── Gemfile ├── config.ru ├── config/puma.rb ├── lib/ │ ├── sent_app.rb │ ├── sent_app/ │ │ ├── api.rb │ │ ├── webhooks.rb │ │ ├── helpers.rb │ │ ├── middleware/ │ │ │ ├── request_logger.rb │ │ │ └── api_authentication.rb │ │ ├── services/message_service.rb │ │ ├── validators/send_message_contract.rb │ │ └── models/message.rb │ └── config.rb ├── db/sequel.rb └── spec/ ├── spec_helper.rb ├── api_spec.rb └── webhooks_spec.rb ``` ## Gemfile ```ruby source 'https://rubygems.org' ruby '~> 3.2' gem 'sinatra', '~> 4.0' gem 'sinatra-contrib', '~> 4.0' gem 'puma', '~> 6.4' gem 'sequel', '~> 5.75' gem 'pg', '~> 1.5' gem 'dry-validation', '~> 1.10' gem 'dry-monads', '~> 1.6' gem 'dotenv', '~> 3.0' gem 'sent_dm', '~> 1.0' group :development do gem 'rerun', '~> 0.14' gem 'pry', '~> 0.14' end group :test do gem 'rspec', '~> 3.12' gem 'rack-test', '~> 2.1' gem 'database_cleaner-sequel', '~> 2.0' gem 'webmock', '~> 3.19' end ``` ## Configuration ```ruby # lib/config.rb require 'dotenv' require 'singleton' Dotenv.load module SentApp class Config include Singleton DEFAULTS = { port: 3000, env: 'development', log_level: 'info', db_pool: 5 }.freeze def self.[](key); instance[key]; end def initialize; @config = DEFAULTS.merge(load_from_env); end def [](key); @config[key.to_sym]; end def fetch(key, default = nil); @config.fetch(key.to_sym, default); end def api_key; @config[:sent_api_key] || raise('SENT_DM_API_KEY required'); end def webhook_secret; @config[:sent_webhook_secret]; end def environment; @config[:env]; end def development?; environment == 'development'; end def test?; environment == 'test'; end def production?; environment == 'production'; end private def load_from_env { port: ENV['PORT']&.to_i, env: ENV['RACK_ENV'] || 'development', sent_api_key: ENV['SENT_DM_API_KEY'], sent_webhook_secret: ENV['SENT_DM_WEBHOOK_SECRET'], sent_base_url: ENV['SENT_BASE_URL'], db_url: ENV['DATABASE_URL'], log_level: ENV['LOG_LEVEL'] }.compact end end end ``` ## Database Setup ```ruby # db/sequel.rb require 'sequel' require_relative '../lib/config' DB = Sequel.connect( SentApp::Config.fetch(:db_url, 'sqlite://db/development.db'), pool_timeout: SentApp::Config.fetch(:db_timeout, 5000), max_connections: SentApp::Config.fetch(:db_pool, 5), logger: SentApp::Config.development? ? Logger.new($stdout) : nil ) DB.extension :connection_validator DB.pool.connection_validation_timeout = -1 ``` ```ruby # lib/sent_app/models/message.rb require_relative '../../../db/sequel' module SentApp module Models class Message < Sequel::Model(:messages) plugin :timestamps, update_on_create: true plugin :validation_helpers STATUSES = %w[pending queued sent delivered failed cancelled].freeze def validate super validates_presence [:phone_number, :template_id] validates_includes STATUSES, :status validates_format /\A\+?[1-9]\d{1,14}\z/, :phone_number end def mark_as_sent!(sent_id); update(sent_id: sent_id, status: 'sent', sent_at: Time.now); end def mark_as_delivered!; update(status: 'delivered', delivered_at: Time.now); end def mark_as_failed!(error); update(status: 'failed', failed_at: Time.now, error_message: error.to_s); end end end end ``` ## Validation Contracts ```ruby # lib/sent_app/validators/send_message_contract.rb require 'dry-validation' module SentApp module Validators class SendMessageContract < Dry::Validation::Contract params do required(:phone_number).filled(:string) required(:template_id).filled(:string) optional(:template_name).maybe(:string) optional(:variables).maybe(:hash) optional(:channels).array(:string) end rule(:phone_number) do key.failure('must be valid E.164 format') unless value.match?(/\A\+?[1-9]\d{1,14}\z/) end rule(:channels) do if key? && value invalid = value - %w[sms whatsapp email push] key.failure("invalid channels: #{invalid.join(', ')}") unless invalid.empty? end end end class WebhookContract < Dry::Validation::Contract params do required(:type).filled(:string) required(:data).filled(:hash) end rule(:type) do allowed = %w[message.delivered message.failed message.sent message.queued message.read] key.failure("unknown event type: #{value}") unless allowed.include?(value) end end end end ``` ## Services Layer ```ruby # lib/sent_app/services/message_service.rb require 'dry/monads' require_relative '../validators/send_message_contract' require_relative '../models/message' module SentApp module Services class MessageService include Dry::Monads[:result, :do] def initialize(client: nil, logger: Logger.new($stdout)) @client = client || Sentdm::Client.new(Config.api_key) @logger = logger end def send_message(params) validation = Validators::SendMessageContract.new.call(params) return Failure(validation.errors.to_h) if validation.failure? validated = validation.to_h message = Models::Message.create( phone_number: validated[:phone_number], template_id: validated[:template_id], template_name: validated[:template_name], variables: validated[:variables]&.to_json, channel: validated[:channels]&.first, status: 'pending' ) result = @client.messages.send_( to: [validated[:phone_number]], template: { id: validated[:template_id], name: validated[:template_name], parameters: validated[:variables] || {} }, channels: validated[:channels] ) if result.success message.mark_as_sent!(result.data.id) Success(message) else message.mark_as_failed!(result.error.message) Failure(error: result.error.message, code: result.error.code) end rescue StandardError => e @logger.error "MessageService error: #{e.message}" message&.mark_as_failed!(e.message) Failure(error: e.message, code: :internal_error) end def send_welcome(phone_number, name: nil) send_message(phone_number: phone_number, template_id: 'welcome-template', template_name: 'welcome', variables: { name: name || 'Customer' }.compact, channels: ['whatsapp']) end def process_webhook(event_data) event = OpenStruct.new(event_data) case event.type when 'message.delivered' then handle_delivered(event.data) when 'message.failed' then handle_failed(event.data) when 'message.sent' then @logger.info "Message #{event.data.id} confirmed sent" else @logger.info "Unhandled webhook: #{event.type}" end Success(event.type) rescue StandardError => e @logger.error "Webhook error: #{e.message}" Failure(error: e.message) end private def handle_delivered(data) return unless data.id message = Models::Message.first(sent_id: data.id) message ? message.mark_as_delivered! : @logger.warn("Message #{data.id} not found") end def handle_failed(data) return unless data.id message = Models::Message.first(sent_id: data.id) message&.mark_as_failed!(data.error&.message || 'Unknown error') end end end end ``` ## Middleware ```ruby # lib/sent_app/middleware/request_logger.rb module SentApp module Middleware class RequestLogger def initialize(app, logger: nil) @app = app @logger = logger || Logger.new($stdout) end def call(env) start = Time.now request_id = "#{Time.now.to_i}-#{SecureRandom.hex(4)}" env['REQUEST_ID'] = request_id @logger.info "[#{request_id}] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} started" status, headers, body = @app.call(env) duration = ((Time.now - start) * 1000).round(2) @logger.info "[#{request_id}] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} completed status=#{status} duration=#{duration}ms" [status, headers, body] rescue StandardError => e @logger.error "[#{request_id}] Error: #{e.message}" raise end end end end ``` ```ruby # lib/sent_app/middleware/api_authentication.rb module SentApp module Middleware class ApiAuthentication def initialize(app, excluded_paths: []) @app = app @excluded_paths = excluded_paths end def call(env) request = Rack::Request.new(env) return @app.call(env) if @excluded_paths.any? { |p| request.path.start_with?(p) } return @app.call(env) if request.path.include?('/webhooks') auth_header = env['HTTP_AUTHORIZATION'] unless auth_header && auth_header.gsub(/Bearer\s+/i, '').length >= 20 return [401, { 'Content-Type' => 'application/json' }, [{ error: 'Unauthorized', message: 'Valid API token required' }.to_json]] end env['AUTHENTICATED'] = true @app.call(env) end end end end ``` ## Helpers ```ruby # lib/sent_app/helpers.rb module SentApp module Helpers def json_response(data, status: 200) content_type :json status status data.to_json end def error_response(message, status: 400, code: nil) error = { error: message } error[:code] = code if code json_response(error, status: status) end def parse_json_body body = request.body.read return {} if body.empty? JSON.parse(body, symbolize_names: true) rescue JSON::ParserError halt 400, error_response('Invalid JSON') end def logger; @logger ||= Logger.new($stdout); end def request_id; env['REQUEST_ID'] || 'unknown'; end def paginate(dataset, page: 1, per_page: 20) page = [page.to_i, 1].max per_page = [[per_page.to_i, 100].min, 1].max paginated = dataset.paginate(page, per_page) { data: paginated.all, pagination: { page: page, per_page: per_page, total: paginated.pagination_record_count, total_pages: (paginated.pagination_record_count.to_f / per_page).ceil } } end def verify_webhook_signature!(payload, signature) return true if Config.test? secret = Config.webhook_secret return true unless secret expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload) halt 401, error_response('Invalid webhook signature') unless Rack::Utils.secure_compare(signature.to_s, "sha256=#{expected}") true end end end ``` ## Application Classes ```ruby # lib/sent_app/api.rb require 'sinatra/base' require_relative 'helpers' require_relative 'services/message_service' module SentApp class Api < Sinatra::Base helpers Helpers error 400..499 do content_type :json { error: response.body.join, status: response.status }.to_json end error 500..599 do content_type :json logger.error "Server error: #{env['sinatra.error']&.message}" { error: 'Internal server error', status: 500 }.to_json end not_found do content_type :json { error: 'Not found', path: request.path }.to_json end before do content_type :json logger.info "[#{request_id}] Processing #{request.request_method} #{request.path}" end after do logger.info "[#{request_id}] Completed #{status}" end get '/health' do json_response(status: 'ok', timestamp: Time.now.iso8601, version: '1.0.0') end post '/messages/send' do data = parse_json_body service = Services::MessageService.new(logger: logger) result = service.send_message(data) result.either( ->(msg) { json_response({ success: true, message_id: msg.id, sent_id: msg.sent_id, status: msg.status }, status: 201) }, ->(err) { error_response(err[:error] || err, status: 400, code: err[:code]) } ) end post '/messages/welcome' do data = parse_json_body halt 400, error_response('phone_number required') unless data[:phone_number] service = Services::MessageService.new(logger: logger) result = service.send_welcome(data[:phone_number], name: data[:name]) result.either( ->(msg) { json_response(success: true, message_id: msg.id, status: msg.status) }, ->(err) { error_response(err[:error] || err, status: 400) } ) end get '/messages/:id' do message = Models::Message[params[:id].to_i] halt 404, error_response('Message not found') unless message json_response(id: message.id, sent_id: message.sent_id, phone_number: message.phone_number, template_id: message.template_id, status: message.status, sent_at: message.sent_at&.iso8601, delivered_at: message.delivered_at&.iso8601, created_at: message.created_at.iso8601) end get '/messages' do dataset = Models::Message.order(Sequel.desc(:created_at)) dataset = dataset.where(status: params[:status]) if params[:status] dataset = dataset.where(phone_number: params[:phone_number]) if params[:phone_number] json_response(paginate(dataset, page: params[:page] || 1, per_page: params[:per_page] || 20)) end end end ``` ```ruby # lib/sent_app/webhooks.rb require 'sinatra/base' require_relative 'helpers' require_relative 'validators/send_message_contract' module SentApp class Webhooks < Sinatra::Base helpers Helpers configure { set :show_exceptions, false } post '/webhooks/sent' do payload = request.body.read signature = env['HTTP_X_WEBHOOK_SIGNATURE'] verify_webhook_signature!(payload, signature) data = parse_json_body validation = Validators::WebhookContract.new.call(data) halt 400, error_response('Invalid webhook payload') unless validation.success? service = Services::MessageService.new(logger: logger) result = service.process_webhook(data) result.either( ->(event_type) { json_response(received: true, event: event_type) }, ->(err) { json_response(received: true, processed: false, error: err[:error]) } ) end get '/webhooks/health' do json_response(status: 'ok', webhooks_enabled: true) end end end ``` ```ruby # lib/sent_app.rb require 'sinatra/base' require_relative 'config' require_relative 'sent_app/helpers' require_relative 'sent_app/models/message' require_relative 'sent_app/api' require_relative 'sent_app/webhooks' require_relative 'sent_app/middleware/request_logger' require_relative 'sent_app/middleware/api_authentication' module SentApp class Application < Sinatra::Base configure do set :environment, Config.environment.to_sym set :root, File.expand_path('..', __dir__) set :sessions, true set :session_secret, Config.fetch(:session_secret, SecureRandom.hex(64)) set :protection, except: :path_traversal set :show_exceptions, false set :raise_errors, false end configure :development do require 'sinatra/reloader' register Sinatra::Reloader also_reload 'lib/**/*.rb' end use Middleware::RequestLogger use Middleware::ApiAuthentication, excluded_paths: ['/health', '/webhooks'] use Api use Webhooks get '/' do json_response(name: 'Sent DM API', version: '1.0.0', documentation: '/docs', health: '/health') end end end ``` ## Rack & Puma Configuration ```ruby # config.ru require_relative 'lib/sent_app' use Rack::Deflater use Rack::ContentType, 'application/json' run SentApp::Application ``` ```ruby # config/puma.rb require_relative '../lib/config' config = SentApp::Config port config.fetch(:port, 3000) environment config.environment workers config.fetch(:puma_workers, 2) threads_count = config.fetch(:puma_threads, 5) threads threads_count, threads_count preload_app! worker_timeout config.fetch(:worker_timeout, 60) pidfile 'tmp/pids/puma.pid' on_worker_boot { DB.disconnect if defined?(DB) } plugin :tmp_restart ``` ## Testing ```ruby # spec/spec_helper.rb ENV['RACK_ENV'] = 'test' require 'rack/test' require 'rspec' require 'database_cleaner-sequel' require 'webmock/rspec' require_relative '../lib/sent_app' require_relative '../db/sequel' DatabaseCleaner.strategy = :transaction DatabaseCleaner.db = DB RSpec.configure do |config| config.include Rack::Test::Methods config.before(:each) { DatabaseCleaner.start } config.after(:each) { DatabaseCleaner.clean } end def app; SentApp::Application; end ``` ```ruby # spec/api_spec.rb require_relative 'spec_helper' RSpec.describe 'Messages API' do let(:mock_client) { instance_double(Sentdm::Client) } let(:mock_messages) { double('messages') } before do allow(Sentdm::Client).to receive(:new).and_return(mock_client) allow(mock_client).to receive(:messages).and_return(mock_messages) end describe 'GET /health' do it 'returns health status' do get '/health' expect(last_response).to be_ok expect(JSON.parse(last_response.body)['status']).to eq('ok') end end describe 'POST /messages/send' do let(:valid_params) { { phone_number: '+1234567890', template_id: 'welcome', variables: { name: 'John' } } } context 'with valid parameters' do before do allow(mock_messages).to receive(:send_).and_return( double('result', success: true, data: double('data', id: 'msg_123')) ) end it 'creates and sends a message' do post '/messages/send', valid_params.to_json, { 'CONTENT_TYPE' => 'application/json' } expect(last_response.status).to eq(201) body = JSON.parse(last_response.body) expect(body['success']).to be true expect(body['sent_id']).to eq('msg_123') end end context 'with invalid parameters' do it 'returns validation errors for missing phone_number' do post '/messages/send', { template_id: 'welcome' }.to_json, { 'CONTENT_TYPE' => 'application/json' } expect(last_response.status).to eq(400) expect(JSON.parse(last_response.body)['error']).to include('phone_number') end it 'returns validation errors for invalid phone format' do post '/messages/send', { phone_number: 'invalid', template_id: 'welcome' }.to_json, { 'CONTENT_TYPE' => 'application/json' } expect(last_response.status).to eq(400) expect(JSON.parse(last_response.body)['error']).to include('E.164') end end end describe 'GET /messages/:id' do let!(:message) do SentApp::Models::Message.create(phone_number: '+1234567890', template_id: 'welcome', status: 'sent', sent_id: 'msg_123') end it 'returns message details' do get "/messages/#{message.id}" expect(last_response).to be_ok body = JSON.parse(last_response.body) expect(body['id']).to eq(message.id) expect(body['status']).to eq('sent') end it 'returns 404 for non-existent message' do get '/messages/99999' expect(last_response.status).to eq(404) end end describe 'GET /messages' do before do 5.times { |i| SentApp::Models::Message.create(phone_number: "+123456789#{i}", template_id: 'welcome', status: 'sent') } end it 'returns paginated messages' do get '/messages?page=1&per_page=3' expect(last_response).to be_ok body = JSON.parse(last_response.body) expect(body['data'].length).to eq(3) expect(body['pagination']['total']).to eq(5) end end end ``` ```ruby # spec/webhooks_spec.rb require_relative 'spec_helper' RSpec.describe 'Webhooks' do describe 'POST /webhooks/sent' do let(:webhook_secret) { 'test_secret' } let(:payload) { { type: 'message.delivered', data: { id: 'msg_123', status: 'delivered' } }.to_json } before do allow(SentApp::Config).to receive(:webhook_secret).and_return(webhook_secret) SentApp::Models::Message.create(phone_number: '+1234567890', template_id: 'welcome', sent_id: 'msg_123', status: 'sent') end def generate_signature(payload, secret) 'sha256=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret, payload) end it 'processes webhook with valid signature' do signature = generate_signature(payload, webhook_secret) post '/webhooks/sent', payload, { 'CONTENT_TYPE' => 'application/json', 'HTTP_X_WEBHOOK_SIGNATURE' => signature } expect(last_response).to be_ok expect(JSON.parse(last_response.body)['received']).to be true end it 'returns 401 with invalid signature' do post '/webhooks/sent', payload, { 'CONTENT_TYPE' => 'application/json', 'HTTP_X_WEBHOOK_SIGNATURE' => 'invalid' } expect(last_response.status).to eq(401) end end end ``` ## Environment Variables ```bash # .env.example SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret SENT_BASE_URL=https://api.sent.dm DATABASE_URL=postgres://user:password@localhost/sent_app_development PORT=3000 RACK_ENV=development LOG_LEVEL=info ``` ## Docker ```dockerfile # Dockerfile FROM ruby:3.2-slim RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY Gemfile Gemfile.lock ./ RUN bundle config set --local deployment 'true' && \ bundle config set --local without 'development test' && \ bundle install COPY . . RUN mkdir -p tmp/pids log EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/health || exit 1 CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] ``` ```yaml # docker-compose.yml version: '3.8' services: app: build: . ports: ["3000:3000"] environment: - RACK_ENV=production - DATABASE_URL=postgres://postgres:password@db:5432/sent_app - SENT_DM_API_KEY=${SENT_DM_API_KEY} - SENT_DM_WEBHOOK_SECRET=${SENT_DM_WEBHOOK_SECRET} depends_on: [db] db: image: postgres:15-alpine environment: [POSTGRES_PASSWORD=password, POSTGRES_DB=sent_app] volumes: [postgres_data:/var/lib/postgresql/data] volumes: postgres_data: ``` ## Running ```bash # Development bundle install cp .env.example .env bundle exec rake db:migrate bundle exec rerun -- puma -C config/puma.rb # Production RACK_ENV=production bundle exec rake db:migrate bundle exec puma -C config/puma.rb # Testing bundle exec rspec # Docker docker-compose up --build ``` ## Next Steps - Review [Ruby SDK documentation](/sdks/ruby) for advanced features - Learn about [webhook best practices](/start/webhooks/getting-started) - Explore [testing patterns](/sdks/testing) for comprehensive coverage ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/testing.txt TITLE: Testing with SDKs ================================================================================ URL: https://docs.sent.dm/llms/sdks/testing.txt Comprehensive testing strategies for Sent SDKs. Unit tests, integration tests, mocking, and CI/CD best practices. # Testing with SDKs Build reliable messaging features with comprehensive testing strategies for Sent LogoSent SDKs. This guide covers unit testing, integration testing, mocking, and CI/CD best practices. ## Testing Pyramid For messaging integrations, follow this testing strategy: 1. **Unit Tests (70%)** - Mock the SDK, test business logic 2. **Integration Tests (20%)** - Use test API keys to validate API calls 3. **E2E Tests (10%)** - Full flow with webhook handling ## Unit Testing ### Mock the SDK Don't make real API calls in unit tests: ```typescript // __mocks__/@sentdm/sentdm.ts import { jest } from '@jest/globals'; export const mockSend = jest.fn(); export default jest.fn().mockImplementation(() => ({ messages: { send: mockSend } })); // notification.service.test.ts import { mockSend } from './__mocks__/@sentdm/sentdm'; import { NotificationService } from './notification.service'; describe('NotificationService', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should send welcome message on user signup', async () => { // Arrange mockSend.mockResolvedValue({ data: { messages: [{ id: 'msg_123', status: 'pending', price: 0.0125 }] } }); const service = new NotificationService(); const user = { phone: '+1234567890', name: 'John' }; // Act await service.sendWelcomeMessage(user); // Assert expect(mockSend).toHaveBeenCalledWith({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome', parameters: { name: 'John' } } }); }); it('should handle send failures gracefully', async () => { // Arrange const error = new Error('Template not found'); error.status = 400; mockSend.mockRejectedValue(error); const service = new NotificationService(); const user = { phone: '+1234567890', name: 'John' }; // Act & Assert await expect(service.sendWelcomeMessage(user)) .rejects.toThrow('Failed to send message'); }); }); ``` ```python # conftest.py import pytest from unittest.mock import Mock @pytest.fixture def mock_sent(): mock = Mock() mock.messages.send.return_value = Mock( data=Mock( messages=[Mock(id='msg_123', status='pending', price=0.0125)] ) ) return mock # test_notification_service.py import pytest from unittest.mock import patch def test_send_welcome_message(mock_sent): # Arrange with patch('myapp.services.SentDm', return_value=mock_sent): from myapp.services import NotificationService service = NotificationService() user = {'phone': '+1234567890', 'name': 'John'} # Act service.send_welcome_message(user) # Assert mock_sent.messages.send.assert_called_once_with( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome', 'parameters': {'name': 'John'} } ) def test_handle_send_failure(mock_sent): # Arrange from sent_dm import BadRequestError mock_sent.messages.send.side_effect = BadRequestError( message='Template not found', response=Mock(status_code=400) ) with patch('myapp.services.SentDm', return_value=mock_sent): from myapp.services import NotificationService service = NotificationService() user = {'phone': '+1234567890', 'name': 'John'} # Act & Assert with pytest.raises(Exception) as exc_info: service.send_welcome_message(user) assert 'Template not found' in str(exc_info.value) ``` ```go // mocks/sent_client.go package mocks import ( "context" "github.com/sentdm/sent-dm-go" ) type MockMessagesService struct { SendFunc func(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) } func (m *MockMessagesService) Send(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) { return m.SendFunc(ctx, params) } // notification_service_test.go func TestSendWelcomeMessage(t *testing.T) { // Arrange mockMessages := &mocks.MockMessagesService{ SendFunc: func(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) { // Verify parameters if len(params.To) != 1 || params.To[0] != "+1234567890" { t.Errorf("Expected phone +1234567890, got %v", params.To) } return &sentdm.MessageSendResponse{ Data: struct { Messages []sentdm.Message `json:"messages"` }{ Messages: []sentdm.Message{ {ID: "msg_123", Status: "pending"}, }, }, }, nil }, } service := NewNotificationService(mockMessages) user := User{Phone: "+1234567890", Name: "John"} // Act err := service.SendWelcomeMessage(context.Background(), user) // Assert assert.NoError(t, err) } ``` ### Testing Error Scenarios Test all error paths: ```typescript const errorScenarios = [ { name: 'AuthenticationError', status: 401, message: 'Should throw on invalid API key', shouldRetry: false }, { name: 'RateLimitError', status: 429, message: 'Should retry on rate limit', shouldRetry: true }, { name: 'BadRequestError', status: 400, message: 'Should not retry on 4xx', shouldRetry: false } ]; errorScenarios.forEach(({ name, status, message, shouldRetry }) => { it(message, async () => { const error = new Error('Test error'); (error as any).status = status; mockSend.mockRejectedValue(error); const result = await service.sendMessage({...}); expect(result.retryAttempted).toBe(shouldRetry); }); }); ``` ## Integration Testing ### Use Test API Keys Use separate test API keys for integration tests: ```typescript // integration/message.test.ts import SentDm from '@sentdm/sentdm'; import SentDm from '@sentdm/sentdm'; describe('Message Integration Tests', () => { let client: SentDm; beforeAll(() => { // Use test API key client = new SentDm(process.env.SENT_DM_API_KEY_TEST!); }); it('should validate message request structure with test_mode', async () => { // Use test_mode to validate without sending real messages const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, testMode: true // Validates but doesn't send }); // Response will have test data expect(response.data.messages[0].id).toBeDefined(); expect(response.data.messages[0].status).toBeDefined(); }); it('should handle invalid template', async () => { try { await client.messages.send({ to: ['+1234567890'], template: { id: 'non-existent-template', name: 'invalid' } }); fail('Should have thrown'); } catch (error) { expect(error).toBeInstanceOf(SentDm.BadRequestError); } }); }); ``` ```python # integration/test_messages.py import os import pytest from sent_dm import SentDm, BadRequestError @pytest.fixture def test_client(): return SentDm(api_key=os.environ['SENT_DM_API_KEY_TEST']) def test_validate_message_structure_with_test_mode(test_client): """Test that valid message structure is accepted using test_mode""" # Use test_mode to validate without sending real messages response = test_client.messages.send( to=["+1234567890"], template={ "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8", "name": "welcome" }, test_mode=True # Validates but doesn't send ) # Response will have test data assert response.data.messages[0].id is not None assert response.data.messages[0].status is not None def test_invalid_template_error(test_client): """Test that invalid template throws error""" with pytest.raises(BadRequestError) as exc_info: test_client.messages.send( to=["+1234567890"], template={ "id": "non-existent-template", "name": "invalid" } ) assert exc_info.value is not None ``` ```go // integration/message_test.go func TestSendMessageIntegrationWithTestMode(t *testing.T) { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY_TEST")), ) ctx := context.Background() // Use TestMode to validate without sending real messages response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, TestMode: sentdm.Bool(true), // Validates but doesn't send }) // Should succeed with test data require.NoError(t, err) assert.NotEmpty(t, response.Data.Messages[0].ID) assert.NotEmpty(t, response.Data.Messages[0].Status) } func TestInvalidTemplateIntegration(t *testing.T) { client := sentdm.NewClient( option.WithAPIKey(os.Getenv("SENT_DM_API_KEY_TEST")), ) ctx := context.Background() _, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("non-existent-template"), Name: sentdm.String("invalid"), }, }) require.Error(t, err) var apiErr *sentdm.Error require.True(t, errors.As(err, &apiErr)) assert.Equal(t, 400, apiErr.StatusCode) } ``` ### Test Webhooks Locally Use tools like ngrok or localtunnel for webhook testing: ```typescript // webhook.test.ts describe('Webhook Integration', () => { let server: Server; let webhookUrl: string; beforeAll(async () => { // Start local server server = app.listen(3001); // Create tunnel (using ngrok or similar) webhookUrl = await createTunnel(3001); }); afterAll(async () => { server.close(); await closeTunnel(); }); it('should verify webhook signature', async () => { const payload = JSON.stringify({ type: 'message.delivered', data: { id: 'msg_123' } }); const signature = generateTestSignature(payload); const response = await request(app) .post('/webhooks/sent') .set('X-Webhook-Signature', signature) .set('Content-Type', 'application/json') .send(payload); expect(response.status).toBe(200); }); }); ``` ## CI/CD Testing ### Environment Setup Configure separate environments for CI/CD: ```yaml # .github/workflows/test.yml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm ci - name: Run unit tests run: npm test - name: Run integration tests env: SENT_DM_API_KEY_TEST: ${{ secrets.SENT_DM_API_KEY_TEST }} run: npm run test:integration ``` ### Test Data Management Use consistent test data: ```typescript // test/fixtures.ts export const testFixtures = { validPhoneNumber: '+15555551234', // test number invalidPhoneNumber: 'invalid', validTemplateId: 'test-welcome-template', invalidTemplateId: 'non-existent-template', mockMessage: { id: 'msg_test_123', status: 'pending', price: 0.0125 } }; // Use in tests import { testFixtures } from './fixtures'; it('should send to valid number', async () => { mockSend.mockResolvedValue({ data: { messages: [testFixtures.mockMessage] } }); const result = await service.sendMessage({ phone: testFixtures.validPhoneNumber }); expect(mockSend).toHaveBeenCalledWith({ to: [testFixtures.validPhoneNumber], template: { id: expect.any(String), name: expect.any(String) } }); }); ``` ### Parallel Test Execution Ensure tests can run in parallel: ```typescript // Use unique identifiers per test it('should send message', async () => { const testId = `test-${Date.now()}-${Math.random()}`; const phoneNumber = `+1555${testId.slice(-7)}`; mockSend.mockResolvedValue({ data: { messages: [{ id: `msg_${testId}`, status: 'pending' }] } }); const result = await service.sendMessage({ phone: phoneNumber }); expect(result.data.messages[0].id).toBe(`msg_${testId}`); }); ``` ## Load Testing Test SDK behavior under load: ```typescript // load-test.ts import SentDm from '@sentdm/sentdm'; async function loadTest() { const client = new SentDm(process.env.SENT_DM_API_KEY_TEST!); const concurrency = 10; const totalRequests = 100; console.log(`Starting load test: ${totalRequests} requests at ${concurrency} concurrency`); const startTime = Date.now(); let successCount = 0; let errorCount = 0; for (let batch = 0; batch < totalRequests / concurrency; batch++) { const promises = Array(concurrency).fill(null).map((_, i) => client.messages.send({ to: [`+1555555${String(batch * concurrency + i).padStart(4, '0')}`], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }).then(() => { successCount++; }).catch(err => { errorCount++; console.log('Error:', err.message); }) ); await Promise.all(promises); } const duration = Date.now() - startTime; console.log(`\nResults:`); console.log(`Duration: ${duration}ms`); console.log(`Success: ${successCount}`); console.log(`Errors: ${errorCount}`); console.log(`RPS: ${(totalRequests / (duration / 1000)).toFixed(2)}`); } loadTest(); ``` ## Test Coverage Checklist Use this checklist to ensure comprehensive test coverage for your messaging integration. ### Core Functionality - [ ] Send message with template - [ ] Send message with variables - [ ] Create contact - [ ] Handle successful response - [ ] Handle error response ### Error Scenarios - [ ] Invalid API key - [ ] Rate limiting - [ ] Invalid template ID - [ ] Template not approved - [ ] Insufficient credits - [ ] Invalid phone number - [ ] Contact opted out ### Webhooks - [ ] Verify webhook signature - [ ] Handle message.status.updated - [ ] Handle message.delivered - [ ] Handle message.failed - [ ] Handle duplicate events (idempotency) - [ ] Handle invalid signatures ### Retry Logic - [ ] Retry on rate limit - [ ] Don't retry on 4xx errors - [ ] Exponential backoff - [ ] Max retry attempts ### Edge Cases - [ ] Empty variables object - [ ] Special characters in message - [ ] Very long messages - [ ] Unicode/emojis - [ ] Concurrent requests --- Comprehensive testing ensures your messaging integration works reliably in production. Aim for 80%+ code coverage with a good mix of unit and integration tests. ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/troubleshooting.txt TITLE: SDK Troubleshooting ================================================================================ URL: https://docs.sent.dm/llms/sdks/troubleshooting.txt Common issues and solutions when using Sent SDKs. Error codes, debugging tips, and FAQs. # SDK Troubleshooting Resolve common issues with Sent LogoSent SDKs. This guide covers frequent errors, debugging strategies, and solutions organized by symptom. ## Quick Diagnostics Before diving into specific issues, check these common causes: ## Common Error Codes ### Authentication Errors #### `401 Unauthorized` - Invalid API key **Symptoms:** ``` AuthenticationError: 401 - Invalid API key ``` **Solutions:** 1. Verify your API key from the [Sent Dashboard](https://app.sent.dm/dashboard/api-keys) 2. Check for extra whitespace or copy-paste errors 3. Ensure you're using the right environment variable (`SENT_DM_API_KEY`) 4. Verify the key hasn't been revoked ```typescript // Debug: Log first 8 characters console.log('API Key:', process.env.SENT_DM_API_KEY?.substring(0, 8) + '...'); ``` --- ### Contact Errors #### `404 Not Found` - Contact not found **Symptoms:** ``` NotFoundError: 404 - Contact not found ``` **Solutions:** ```typescript // Create contact first try { const contact = await client.contacts.create({ phoneNumber: '+1234567890' }); console.log('Created contact:', contact.id); } catch (error) { console.error('Failed to create contact:', error.message); } ``` ```python # Create contact first try: contact = client.contacts.create( phone_number='+1234567890' ) print(f'Created contact: {contact.id}') except Exception as e: print(f'Failed to create contact: {e}') ``` ```go // Create contact first err := client.Contacts.Create(ctx, sentdm.ContactCreateParams{ PhoneNumber: "+1234567890", }) if err != nil { log.Printf("Failed to create contact: %v", err) } ``` You can send messages directly to a phone number without creating a contact first: ```typescript // Send directly to phone number const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); ``` ```python # Send directly to phone number response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) ``` ```go // Send directly to phone number response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{ To: []string{"+1234567890"}, Template: sentdm.MessageSendParamsTemplate{ ID: sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"), Name: sentdm.String("welcome"), }, }) ``` #### Contact opted out **Symptoms:** ``` BadRequestError: 400 - Contact has opted out of messaging ``` **Solution:** The recipient has opted out. Respect their preference and don't retry. ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); } catch (error) { if (error instanceof SentDm.BadRequestError) { if (error.message.includes('opted out')) { // Update your database await db.users.update({ where: { phone: '+1234567890' }, data: { messagingOptOut: true } }); // Don't retry - respect opt-out console.log('Contact opted out - skipping'); } } } ``` ```python from sent_dm import BadRequestError try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) except BadRequestError as e: if 'opted out' in str(e): # Update your database db.users.update( phone='+1234567890', messaging_opt_out=True ) # Don't retry - respect opt-out print('Contact opted out - skipping') ``` --- ### Template Errors #### `400 Bad Request` - Template not found **Symptoms:** ``` BadRequestError: 400 - Template not found ``` **Solutions:** ```typescript // List all templates to find the correct ID try { const templates = await client.templates.list(); templates.data.forEach(t => { console.log(`${t.name}: ${t.id} (${t.status})`); }); } catch (error) { console.error('Failed to list templates:', error.message); } ``` ```python # List all templates to find the correct ID try: templates = client.templates.list() for t in templates.data: print(f'{t.name}: {t.id} ({t.status})') except Exception as e: print(f'Failed to list templates: {e}') ``` ```typescript try { const template = await client.templates.get('your-template-id'); console.log('Status:', template.status); // APPROVED, PENDING, REJECTED console.log('Channels:', template.channels); } catch (error) { console.error('Template not found:', error.message); } ``` ```python try: template = client.templates.get('your-template-id') print(f'Status: {template.status}') # APPROVED, PENDING, REJECTED print(f'Channels: {template.channels}') except Exception as e: print(f'Template not found: {e}') ``` #### `400 Bad Request` - WhatsApp template pending **Symptoms:** ``` BadRequestError: 400 - Template is not approved for WhatsApp ``` **Solutions:** 1. **Check template status in dashboard** - WhatsApp templates need Meta approval (can take hours) - SMS templates work immediately 2. **Use SMS as fallback** ```typescript // Try sending (may throw if template not approved) try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log('Sent:', response.data.messages[0].id); } catch (error) { if (error instanceof SentDm.BadRequestError && error.message.includes('not approved')) { console.log('Template not approved yet'); // Queue for later or use alternative channel await db.queuedMessages.create({ phoneNumber: '+1234567890', templateId: 'welcome-template', retryAfter: new Date(Date.now() + 3600000), // 1 hour status: 'pending_approval' }); } } ``` ```python from sent_dm import BadRequestError # Try sending (may raise if template not approved) try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) print(f'Sent: {response.data.messages[0].id}') except BadRequestError as e: if 'not approved' in str(e): print('Template not approved yet') # Queue for later or use alternative channel db.queued_messages.create( phone_number='+1234567890', template_id='welcome-template', retry_after=datetime.now() + timedelta(hours=1), status='pending_approval' ) ``` --- ### Rate Limiting #### `429 Rate Limit` - Too many requests **Symptoms:** ``` RateLimitError: 429 - Rate limit exceeded Retry-After: 60 ``` **Solutions:** SDKs have built-in retry logic, but you can also handle it manually: ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm({ maxRetries: 3 // Built-in retry with exponential backoff }); // Or handle manually try { const response = await client.messages.send(params); } catch (error) { if (error instanceof SentDm.RateLimitError) { const retryAfter = parseInt( error.headers['retry-after'] || '60', 10 ); console.log(`Rate limited. Retrying after ${retryAfter}s...`); await sleep(retryAfter * 1000); // Retry const response = await client.messages.send(params); } } ``` ```python import time from sent_dm import SentDm, RateLimitError client = SentDm(max_retries=3) # Built-in retry with exponential backoff # Or handle manually try: response = client.messages.send(...) except RateLimitError as e: retry_after = int(e.response.headers.get('retry-after', 60)) print(f'Rate limited. Retrying after {retry_after}s...') time.sleep(retry_after) # Retry response = client.messages.send(...) ``` ```typescript import pLimit from 'p-limit'; // Limit to 5 concurrent requests const limit = pLimit(5); const results = await Promise.all( recipients.map(phone => limit(() => client.messages.send({ to: [phone], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'notification' } })) ) ); ``` Default rate limits: - 100 requests per second per API key - 1000 requests per minute per API key Contact support if you need higher limits. --- ### Billing Errors #### Payment Required - Account balance too low **Symptoms:** ``` BadRequestError: 400 - Insufficient credits to send message ``` **Solutions:** 1. **Add credits in the dashboard** - Go to [Billing](https://app.sent.dm/dashboard/billing) 2. **Graceful degradation** ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); } catch (error) { if (error instanceof SentDm.BadRequestError && error.message.includes('credits')) { // Queue for later await db.messageQueue.create({ ...messageData, status: 'pending_credits' }); await notifyOpsTeam('Account balance low'); } } ``` ```python from sent_dm import BadRequestError try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) except BadRequestError as e: if 'credits' in str(e): # Queue for later db.message_queue.create( status='pending_credits', **message_data ) notify_ops_team('Account balance low') ``` --- ## Webhook Issues ### Webhook not receiving events ```bash # Test if your endpoint is reachable curl -X POST https://your-app.com/webhooks/sent \ -H "Content-Type: application/json" \ -d '{"test": true}' ``` Make sure your endpoint: - Is publicly accessible (not localhost) - Uses HTTPS (required) - Returns 200 OK quickly Common mistakes: - Using the wrong secret - Not using raw request body - Case-sensitive header names ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // ❌ Wrong - use raw body, not parsed JSON app.post('/webhooks/sent', (req, res) => { const payload = req.body; // Parsed JSON won't work // ... }); // ✅ Correct - use raw body app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body.toString(); // Raw string const signature = req.headers['x-webhook-signature']; const isValid = client.webhooks.verifySignature({ payload, signature, secret: process.env.SENT_DM_WEBHOOK_SECRET }); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } res.json({ received: true }); } ); ``` ```python from sent_dm import SentDm client = SentDm() @app.route('/webhooks/sent', methods=['POST']) def webhook(): signature = request.headers.get('X-Webhook-Signature') payload = request.get_data(as_text=True) # Raw body is_valid = client.webhooks.verify_signature( payload=payload, signature=signature, secret=os.environ['SENT_DM_WEBHOOK_SECRET'] ) if not is_valid: return jsonify({'error': 'Invalid signature'}), 401 return jsonify({'received': True}) ``` Make sure you're subscribed to the right events: ```typescript // List your webhooks (via API or dashboard) // Verify event types are correct console.log('Expected webhook events:'); console.log('- message.status.updated'); console.log('- message.delivered'); console.log('- message.failed'); ``` ```python # List your webhooks (via API or dashboard) # Verify event types are correct print('Expected webhook events:') print('- message.status.updated') print('- message.delivered') print('- message.failed') ``` ### Duplicate webhook events Webhook events may be delivered multiple times. Handle them idempotently: ```typescript async function handleWebhook(event: WebhookEvent) { const eventId = event.meta?.request_id || event.id; // Check if already processed const existing = await db.processedEvents.findUnique({ where: { eventId } }); if (existing) { console.log(`Event ${eventId} already processed`); return { received: true }; } // Process event... // Mark as processed await db.processedEvents.create({ data: { eventId, processedAt: new Date() } }); } ``` --- ## Connection Issues ### Timeout errors **Symptoms:** ``` APIConnectionError: Request timeout after 30000ms ``` **Solutions:** 1. **Increase timeout** ```typescript const client = new SentDm({ timeout: 60000 // 60 seconds }); ``` ```python from sent_dm import SentDm client = SentDm(timeout=60.0) # 60 seconds ``` ```go client := sentdm.NewClient( option.WithTimeout(60 * time.Second), ) ``` 2. **Check network connectivity** ```bash # Test API reachability curl https://api.sent.dm/v3/health ``` 3. **Implement circuit breaker** ```typescript class CircuitBreaker { private failures = 0; private lastFailureTime?: number; private readonly threshold = 5; private readonly timeout = 60000; async execute(fn: () => Promise): Promise { if (this.isOpen()) { throw new Error('Circuit breaker is open'); } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private isOpen(): boolean { if (this.failures < this.threshold) return false; if (!this.lastFailureTime) return false; return Date.now() - this.lastFailureTime < this.timeout; } private onSuccess() { this.failures = 0; } private onFailure() { this.failures++; this.lastFailureTime = Date.now(); } } ``` --- ## Debugging Tips ### Enable Debug Logging Most SDKs support debug logging via environment variables: ```bash # Set environment variable export SENT_DM_LOG=debug ``` Or configure in code: ```typescript const client = new SentDm({ logLevel: 'debug' // 'debug', 'info', 'warn', 'error', 'off' }); ``` ```bash # Set environment variable export SENT_DM_LOG=debug ``` Or use Python logging: ```python import logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger('sent_dm') logger.setLevel(logging.DEBUG) ``` ```go // Use the debug option client := sentdm.NewClient( option.WithDebugLog(nil), ) ``` ```bash # Set system property java -Dsentdm.log.level=DEBUG -jar myapp.jar ``` ### Log Request IDs Always log information for support tickets: ```typescript try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); console.log('Message sent:', response.data.messages[0].id); } catch (error) { if (error instanceof SentDm.APIError) { console.log('Request ID:', error.headers['x-request-id']); console.log('Status:', error.status); console.log('Message:', error.message); // Include these in support tickets! } } ``` ```python from sent_dm import APIStatusError try: response = client.messages.send( to=['+1234567890'], template={ 'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8', 'name': 'welcome' } ) print(f'Message sent: {response.data.messages[0].id}') except APIStatusError as e: print(f'Request ID: {e.response.headers.get("x-request-id")}') print(f'Status: {e.status_code}') print(f'Message: {str(e)}') # Include these in support tickets! ``` ```go response, err := client.Messages.Send(ctx, params) if err != nil { var apiErr *sentdm.Error if errors.As(err, &apiErr) { fmt.Printf("Request ID: %s\n", apiErr.RequestID) fmt.Printf("Status: %d\n", apiErr.StatusCode) fmt.Printf("Message: %s\n", apiErr.Message) // Include these in support tickets! } } else { fmt.Printf("Message sent: %s\n", response.Data.Messages[0].ID) } ``` ### Test with cURL Compare SDK behavior with raw API calls: ```bash # Test authentication curl -X GET https://api.sent.dm/v3/templates \ -H "x-api-key: YOUR_API_KEY" # Test message sending curl -X POST https://api.sent.dm/v3/messages \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+1234567890", "template_id": "your-template-id" }' ``` --- ## Frequently Asked Questions ### Q: Why is my message stuck in "pending" status? **A:** Messages start as "pending" and transition through: 1. `pending` → Message accepted, queued for sending 2. `sent` → Dispatched to carrier/WhatsApp 3. `delivered` → Confirmed delivery 4. `read` → (WhatsApp only) Opened by recipient Use webhooks to track status changes. Polling is not supported. ### Q: Can I use the same API key for multiple environments? **A:** Yes, but it's not recommended. Use separate keys for different environments and explicitly pass them to the SDK: ```typescript // Choose key based on environment const apiKey = process.env.NODE_ENV === 'production' ? process.env.SENT_DM_API_KEY : process.env.SENT_DM_API_KEY_TEST; const client = new SentDm(apiKey); ``` This prevents accidental sends from test environments. Note: The SDK only automatically reads `SENT_DM_API_KEY` - you must implement the environment switching logic yourself. ### Q: Why am I getting 401 errors in production but not locally? **A:** Common causes: 1. Different API keys (check environment variables) 2. Key not set in production environment 3. Key was revoked/rotated 4. Whitespace or encoding issues Debug by logging the key prefix: ```typescript console.log('Key prefix:', process.env.SENT_DM_API_KEY?.substring(0, 8)); ``` ### Q: How do I handle webhook failures? **A:** Sent will retry failed webhooks with exponential backoff: - Retry 1: After 1 minute - Retry 2: After 5 minutes - Retry 3: After 15 minutes Ensure your endpoint is idempotent and responds quickly. ### Q: Can I send messages from the browser? **A:** No. Never expose your API key in client-side code. API keys should only be used server-side. For browser-based messaging, route through your backend API. --- ## Getting Help If you're still stuck: 1. **Check the [API Reference](/reference/api)** for detailed endpoint documentation 2. **Review [SDK Guides](/sdks)** for language-specific examples 3. **Contact Support** with your request ID: - Email: support@sent.dm - Include: Request ID from error response (in `x-request-id` header) - Include: Timestamp of the failed request - Include: Code snippet (remove API keys!) When contacting support, always include the request ID from the API response headers (`x-request-id`). This helps us trace the exact request in our logs. --- ## SDK-Specific Error Patterns Different SDKs handle errors differently. Here's a quick reference: | SDK | Error Pattern | Key Exception Types | |-----|---------------|---------------------| | **TypeScript** | Throws exceptions | `APIError`, `BadRequestError`, `RateLimitError`, `AuthenticationError` | | **Python** | Throws exceptions | `APIError`, `BadRequestError`, `RateLimitError`, `AuthenticationError` | | **Go** | Returns error value | `*sentdm.Error` with `StatusCode`, `Message`, `RequestID` | | **Java** | Throws exceptions | `SentDmException`, `BadRequestException`, `RateLimitException` | | **C#** | Throws exceptions | `SentDmApiException`, `SentDmBadRequestException`, `SentDmRateLimitException` | | **PHP** | Throws exceptions | `APIException`, `BadRequestException`, `RateLimitException` | | **Ruby** | Throws exceptions | `APIError`, `BadRequestError`, `RateLimitError` | ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript.txt TITLE: TypeScript SDK ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript.txt Official TypeScript SDK for Sent. Send SMS and WhatsApp messages with full type safety and intelligent autocomplete. # TypeScript SDK The official TypeScript SDK for Sent LogoSent provides type-safe access to the entire Sent API. Built for modern Node.js applications with native ESM and CommonJS support, automatic retries, and comprehensive error handling. ## Installation ```bash npm install @sentdm/sentdm ``` ```bash yarn add @sentdm/sentdm ``` ```bash pnpm add @sentdm/sentdm ``` ```bash bun add @sentdm/sentdm ``` ## Quick Start ### Initialize the client ```typescript import SentDm from '@sentdm/sentdm'; const client = new SentDm(); // Uses SENT_DM_API_KEY env var by default ``` ### Send your first message ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome', parameters: { name: 'John Doe' } } }); console.log('Message sent:', response.data.messages[0].id); console.log('Status:', response.data.messages[0].status); ``` ## Authentication The client can be configured using environment variables or explicitly: ```typescript import SentDm from '@sentdm/sentdm'; // Using environment variables (recommended) // SENT_DM_API_KEY=your_api_key const client = new SentDm(); // Or explicit configuration const client = new SentDm({ apiKey: 'your_api_key', }); ``` ## Send Messages ### Send a message ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome', parameters: { name: 'John Doe', order_id: '12345' } }, channel: ['whatsapp', 'sms'] // Optional: defaults to template channels }); console.log('Message ID:', response.data.messages[0].id); console.log('Status:', response.data.messages[0].status); ``` ### Test mode Use `testMode` to validate requests without sending real messages: ```typescript const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' }, testMode: true // Validates but doesn't send }); // Response will have test data console.log('Validation passed:', response.data.messages[0].id); ``` ## Handle errors When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `APIError` will be thrown: ```ts try { const response = await client.messages.send({ to: ['+1234567890'], template: { id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8', name: 'welcome' } }); } catch (err) { if (err instanceof SentDm.APIError) { console.log(err.status); // 400 console.log(err.name); // BadRequestError console.log(err.headers); // {server: 'nginx', ...} } else { throw err; } } ``` Error codes are as follows: | Status Code | Error Type | |-------------|--------------------------| | 400 | `BadRequestError` | | 401 | `AuthenticationError` | | 403 | `PermissionDeniedError` | | 404 | `NotFoundError` | | 422 | `UnprocessableEntityError` | | 429 | `RateLimitError` | | >=500 | `InternalServerError` | | N/A | `APIConnectionError` | ## Retries Certain errors will be automatically retried 2 times by default, with a short exponential backoff. Connection errors, 408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errors will all be retried by default. ```js // Configure the default for all requests: const client = new SentDm({ maxRetries: 0, // default is 2 }); // Or, configure per-request: await client.messages.send({ to: ['+1234567890'], template: { id: 'welcome-template', name: 'welcome' } }, { maxRetries: 5, }); ``` ## Timeouts Requests time out after 1 minute by default. You can configure this with a `timeout` option: ```ts // Configure the default for all requests: const client = new SentDm({ timeout: 20 * 1000, // 20 seconds (default is 1 minute) }); // Override per-request: await client.messages.send({ to: ['+1234567890'], template: { id: 'welcome-template', name: 'welcome' } }, { timeout: 5 * 1000, }); ``` ## Contacts Create and manage contacts: ```typescript // Create a contact const contact = await client.contacts.create({ phoneNumber: '+1234567890', }); console.log('Contact ID:', contact.data.id); // List contacts const contacts = await client.contacts.list({ limit: 100, }); console.log('Total:', contacts.data.length); // Get a contact const contact = await client.contacts.get('contact-uuid'); // Update a contact const updated = await client.contacts.update('contact-uuid', { phoneNumber: '+1987654321', }); // Delete a contact await client.contacts.delete('contact-uuid'); ``` ## Templates List and retrieve templates: ```typescript // List all templates const templates = await client.templates.list(); for (const template of templates.data) { console.log(`${template.name} (${template.status}): ${template.id}`); } // Get a specific template const template = await client.templates.get('template-uuid'); console.log('Template name:', template.data.name); console.log('Status:', template.data.status); ``` ## Framework Integration ### NestJS See the [NestJS Integration](/sdks/typescript/integrations/nestjs) guide for complete dependency injection, module setup, and testing examples. ```typescript // messages/messages.service.ts import { Injectable, Inject } from '@nestjs/common'; import SentDm from '@sentdm/sentdm'; import { SENT_CLIENT } from '../sent/sent.module'; @Injectable() export class MessagesService { constructor( @Inject(SENT_CLIENT) private readonly sentClient: SentDm, ) {} async sendWelcomeMessage(phoneNumber: string, name: string) { const response = await this.sentClient.messages.send({ to: [phoneNumber], template: { id: 'welcome-template', name: 'welcome', parameters: { name } } }); return response.data.messages[0]; } } ``` ### Next.js (App Router) See the [Next.js Integration](/sdks/typescript/integrations/nextjs) guide for complete Server Actions, Route Handlers, and Edge Runtime examples. ```typescript // app/api/send-message/route.ts import SentDm from '@sentdm/sentdm'; import { NextResponse } from 'next/server'; const client = new SentDm(); export async function POST(request: Request) { const { phoneNumber, templateId, variables } = await request.json(); try { const response = await client.messages.send({ to: [phoneNumber], template: { id: templateId, name: 'welcome', parameters: variables } }); return NextResponse.json({ messageId: response.data.messages[0].id, status: response.data.messages[0].status, }); } catch (error) { if (error instanceof SentDm.APIError) { return NextResponse.json( { error: error.message }, { status: error.status } ); } throw error; } } ``` ### Express.js See the [Express.js Integration](/sdks/typescript/integrations/express) guide for complete dependency injection, validation, and structured logging examples. ```typescript import express from 'express'; import SentDm from '@sentdm/sentdm'; const app = express(); const client = new SentDm(); app.use(express.json()); app.post('/send-message', async (req, res) => { const { phoneNumber, templateId, variables } = req.body; try { const response = await client.messages.send({ to: [phoneNumber], template: { id: templateId, name: 'welcome', parameters: variables } }); res.json({ success: true, message: response.data, }); } catch (error) { if (error instanceof SentDm.APIError) { res.status(error.status).json({ error: error.message }); } else { res.status(500).json({ error: 'Internal server error' }); } } }); app.listen(3000); ``` ## Webhooks **Recommended pattern:** Webhooks are the primary way to track message delivery — don't poll the API. Save the message ID when you send, then update your database as webhook events arrive. Sent delivers signed POST requests to your endpoint for every status change. Two event types exist: - **`messages`** — Message status changes (`SENT`, `DELIVERED`, `READ`, `FAILED`, …) - **`templates`** — WhatsApp template approval/rejection Every request includes these headers: | Header | Description | |--------|-------------| | `X-Webhook-ID` | UUID of the webhook configuration | | `X-Webhook-Timestamp` | Unix timestamp (seconds) when the event was created | | `X-Webhook-Signature` | `v1,{base64}` — HMAC-SHA256 of `{webhookId}.{timestamp}.{rawBody}` | | `X-Webhook-Event-Type` | `messages` or `templates` | The signing secret (from the Sent Dashboard) has a `whsec_` prefix. Strip it and **base64-decode** the remainder to obtain the raw HMAC key. ```typescript import express from 'express'; import { createHmac, timingSafeEqual } from 'crypto'; const app = express(); // Must use raw body — do NOT use express.json() for this route app.post('/webhooks/sent', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body as Buffer; const webhookId = req.headers['x-webhook-id'] as string; const timestamp = req.headers['x-webhook-timestamp'] as string; const signature = req.headers['x-webhook-signature'] as string; // 1. Verify: signed content = "{webhookId}.{timestamp}.{rawBody}" const secret = process.env.SENT_WEBHOOK_SECRET!; // "whsec_abc123..." const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signed = `${webhookId}.${timestamp}.${payload.toString('utf8')}`; const expected = 'v1,' + createHmac('sha256', keyBytes).update(signed).digest('base64'); if (!signature || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).json({ error: 'Invalid signature' }); } // 2. Optional: reject replayed events older than 5 minutes if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.status(401).json({ error: 'Timestamp too old' }); } const event = JSON.parse(payload.toString()); // 3. Handle events — update message status in your own database if (event.field === 'messages') { const { message_id, message_status, channel } = event.payload; // await db.messages.update({ where: { sentId: message_id }, data: { status: message_status } }) console.log(`Message ${message_id} → ${message_status} (${channel})`); } // 4. Always return 200 quickly res.json({ received: true }); }); ``` See the [Webhooks reference](/reference/api/webhooks) for the full payload schema and all status values. ## Logging The log level can be configured via the `SENT_DM_LOG` environment variable or using the `logLevel` client option: ```ts import SentDm from '@sentdm/sentdm'; const client = new SentDm({ logLevel: 'debug', // Show all log messages }); ``` Available log levels: `'debug'`, `'info'`, `'warn'` (default), `'error'`, `'off'` ## Source & Issues - **Version**: 0.20.0 - **GitHub**: [sentdm/sent-dm-typescript](https://github.com/sentdm/sent-dm-typescript) - **NPM**: [@sentdm/sentdm](https://www.npmjs.com/package/@sentdm/sentdm) - **Issues**: [Report a bug](https://github.com/sentdm/sent-dm-typescript/issues) ## Getting Help - **Documentation**: [API Reference](/reference/api) - **Troubleshooting**: [Common Issues](/sdks/troubleshooting) - **Support**: Email [support@sent.dm](mailto:support@sent.dm) with your request ID ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/integrations/express.txt TITLE: Express.js Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/integrations/express.txt Production-ready Express.js integration with dependency injection, validation, and structured logging # Express.js Integration Production-ready Express.js integration with dependency injection, validation, structured logging, and modular architecture. This example follows Express.js 5 best practices with TypeScript, including service layer pattern, Zod validation, and proper error handling. ## Project Structure ``` src/ ├── config/ │ └── env.ts # Environment configuration ├── container/ │ └── index.ts # Dependency injection container ├── controllers/ │ ├── health.controller.ts # Health check endpoints │ ├── messages.controller.ts # Message API endpoints │ └── webhooks.controller.ts # Webhook handlers ├── middleware/ │ ├── async-handler.ts # Async error wrapper │ ├── error-handler.ts # Global error handler │ ├── request-logger.ts # Request logging │ └── validate.ts # Zod validation middleware ├── services/ │ ├── messages.service.ts # Business logic │ └── sent.service.ts # SDK client wrapper ├── types/ │ └── index.ts # TypeScript interfaces ├── app.ts # Express app configuration └── server.ts # Server bootstrap ``` ## Dependencies ```bash npm install express @sentdm/sentdm zod pino pino-pretty express-rate-limit helmet cors npm install -D @types/express @types/node typescript ts-node nodemon vitest supertest @types/supertest ``` ## Configuration ### Environment Configuration ```typescript // src/config/env.ts import { z } from 'zod'; const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), PORT: z.string().transform(Number).default('3000'), SENT_DM_API_KEY: z.string().min(1, 'SENT_DM_API_KEY is required'), SENT_DM_WEBHOOK_SECRET: z.string().min(1, 'SENT_DM_WEBHOOK_SECRET is required'), LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('900000'), RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default('100'), }); export type Env = z.infer; const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error('❌ Invalid environment variables:', parsed.error.format()); process.exit(1); } export const env = parsed.data; ``` ### Logger Configuration ```typescript // src/config/logger.ts import pino from 'pino'; import { env } from './env'; export const logger = pino({ level: env.LOG_LEVEL, transport: env.NODE_ENV === 'development' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, base: { pid: process.pid, env: env.NODE_ENV }, }); export type Logger = typeof logger; ``` ## Dependency Injection Container ```typescript // src/container/index.ts import SentDm from '@sentdm/sentdm'; import { env } from '../config/env'; import { logger } from '../config/logger'; import { SentService } from '../services/sent.service'; import { MessagesService } from '../services/messages.service'; export interface Container { logger: typeof logger; sentService: SentService; messagesService: MessagesService; } export function createContainer(): Container { const sentClient = new SentDm(env.SENT_DM_API_KEY); const sentService = new SentService(sentClient, logger); const messagesService = new MessagesService(sentService, logger); return { logger, sentService, messagesService }; } let container: Container | null = null; export function getContainer(): Container { if (!container) container = createContainer(); return container; } export function setContainer(c: Container): void { container = c; } export function resetContainer(): void { container = null; } ``` ## Types and DTOs ```typescript // src/types/index.ts import { z } from 'zod'; export const SendMessageSchema = z.object({ phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'), templateId: z.string().uuid('Invalid template ID'), templateName: z.string().min(1).max(100), parameters: z.record(z.string()).optional(), channels: z.array(z.enum(['whatsapp', 'sms', 'email'])).optional(), }); export const WelcomeMessageSchema = z.object({ phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'), name: z.string().min(1).max(100).optional(), }); export const OrderConfirmationSchema = z.object({ phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 format'), orderNumber: z.string().min(1), total: z.string().regex(/^\d+\.?\d{0,2}$/, 'Invalid amount'), }); export type SendMessageDto = z.infer; export type WelcomeMessageDto = z.infer; export type OrderConfirmationDto = z.infer; export interface MessageResponse { messageId: string; status: string; channels: string[]; } export const WebhookEventSchema = z.object({ type: z.enum(['message.status.updated', 'message.delivered', 'message.failed', 'message.read']), data: z.object({ id: z.string(), status: z.string().optional(), error: z.object({ message: z.string(), code: z.string().optional() }).optional(), timestamp: z.string().datetime().optional(), }), }); export type WebhookEvent = z.infer; export class ApiError extends Error { constructor( public readonly statusCode: number, public readonly code: string, message: string, public readonly details?: Record ) { super(message); this.name = 'ApiError'; Error.captureStackTrace(this, this.constructor); } } ``` ## Services ### Sent Service (SDK Wrapper) ```typescript // src/services/sent.service.ts import type SentDm from '@sentdm/sentdm'; import type { Logger } from '../config/logger'; import { ApiError } from '../types'; export interface SentMessageInput { to: string[]; template: { id: string; name: string; parameters?: Record }; channels?: string[]; } export interface SentMessageOutput { id: string; status: string; channel: string; } export class SentService { constructor(private readonly client: SentDm, private readonly logger: Logger) {} async sendMessage(input: SentMessageInput): Promise { try { const response = await this.client.messages.send({ to: input.to, template: input.template, channels: input.channels, }); const messages = response.data.messages; this.logger.info({ messageIds: messages.map(m => m.id) }, `Sent ${messages.length} message(s)`); return messages.map(msg => ({ id: msg.id, status: msg.status, channel: msg.channel || 'unknown' })); } catch (error) { this.logger.error({ error, input }, 'Failed to send message'); if (error instanceof this.client.APIError) { throw new ApiError(error.status || 500, error.name, error.message, { headers: error.headers }); } throw new ApiError(500, 'InternalError', 'Failed to send message'); } } verifyWebhookSignature(payload: Buffer, signature: string, secret: string): boolean { try { return this.client.webhooks.verifySignature({ payload, signature, secret }); } catch (error) { this.logger.error({ error }, 'Webhook signature verification failed'); return false; } } constructWebhookEvent(payload: Buffer): unknown { try { return JSON.parse(payload.toString()); } catch { throw new ApiError(400, 'InvalidPayload', 'Invalid JSON in webhook payload'); } } } ``` ### Messages Service (Business Logic) ```typescript // src/services/messages.service.ts import type { Logger } from '../config/logger'; import { SentService } from './sent.service'; import type { SendMessageDto, WelcomeMessageDto, OrderConfirmationDto, MessageResponse } from '../types'; export class MessagesService { constructor(private readonly sentService: SentService, private readonly logger: Logger) {} async sendMessage(dto: SendMessageDto): Promise { const messages = await this.sentService.sendMessage({ to: [dto.phoneNumber], template: { id: dto.templateId, name: dto.templateName, parameters: dto.parameters }, channels: dto.channels, }); const primary = messages[0]; return { messageId: primary.id, status: primary.status, channels: messages.map(m => m.channel) }; } async sendWelcomeMessage(dto: WelcomeMessageDto): Promise { this.logger.info({ phoneNumber: dto.phoneNumber }, 'Sending welcome message'); return this.sendMessage({ phoneNumber: dto.phoneNumber, templateId: 'welcome-template-id', templateName: 'welcome', parameters: { name: dto.name || 'Valued Customer' }, channels: ['whatsapp'], }); } async sendOrderConfirmation(dto: OrderConfirmationDto): Promise { this.logger.info({ phoneNumber: dto.phoneNumber, orderNumber: dto.orderNumber }, 'Sending order confirmation'); return this.sendMessage({ phoneNumber: dto.phoneNumber, templateId: 'order-confirmation-id', templateName: 'order_confirmation', parameters: { order_number: dto.orderNumber, total: dto.total }, channels: ['sms', 'whatsapp'], }); } } ``` ## Middleware ```typescript // src/middleware/async-handler.ts import type { Request, Response, NextFunction, RequestHandler } from 'express'; type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise; export function asyncHandler(fn: AsyncRequestHandler): RequestHandler { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } ``` ```typescript // src/middleware/validate.ts import type { Request, Response, NextFunction } from 'express'; import { z, ZodError } from 'zod'; import { ApiError } from '../types'; export function validateBody(schema: z.ZodSchema) { return (req: Request, _res: Response, next: NextFunction): void => { try { req.body = schema.parse(req.body); next(); } catch (error) { if (error instanceof ZodError) { const details = error.errors.map(e => ({ path: e.path.join('.'), message: e.message })); next(new ApiError(400, 'ValidationError', 'Request validation failed', { errors: details })); } else { next(error); } } }; } export function validateParams(schema: z.ZodSchema) { return (req: Request, _res: Response, next: NextFunction): void => { try { req.params = schema.parse(req.params) as Record; next(); } catch (error) { if (error instanceof ZodError) { next(new ApiError(400, 'ValidationError', 'Invalid URL parameters')); } else { next(error); } } }; } ``` ```typescript // src/middleware/error-handler.ts import type { Request, Response, NextFunction } from 'express'; import { env } from '../config/env'; import { logger } from '../config/logger'; import { ApiError } from '../types'; interface ErrorResponse { error: { code: string; message: string; details?: Record; stack?: string }; timestamp: string; path: string; } export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void { const timestamp = new Date().toISOString(); if (err instanceof ApiError) { logger.warn({ statusCode: err.statusCode, code: err.code, path: req.path, message: err.message }, 'API error'); const response: ErrorResponse = { error: { code: err.code, message: err.message, details: err.details }, timestamp, path: req.path }; res.status(err.statusCode).json(response); return; } logger.error({ error: err.message, stack: err.stack, path: req.path, method: req.method }, 'Unexpected error'); const response: ErrorResponse = { error: { code: 'InternalError', message: env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message }, timestamp, path: req.path, }; if (env.NODE_ENV === 'development') response.error.stack = err.stack; res.status(500).json(response); } ``` ```typescript // src/middleware/request-logger.ts import type { Request, Response, NextFunction } from 'express'; import { logger } from '../config/logger'; export function requestLogger(req: Request, res: Response, next: NextFunction): void { const start = Date.now(); const requestId = crypto.randomUUID(); res.setHeader('X-Request-Id', requestId); logger.trace({ requestId, method: req.method, path: req.path, query: req.query, ip: req.ip }, 'Incoming request'); res.on('finish', () => { const duration = Date.now() - start; const logData = { requestId, method: req.method, path: req.path, statusCode: res.statusCode, duration: `${duration}ms` }; if (res.statusCode >= 500) logger.error(logData, 'Request failed'); else if (res.statusCode >= 400) logger.warn(logData, 'Request failed'); else logger.debug(logData, 'Request completed'); }); next(); } ``` ## Controllers ### Messages Controller ```typescript // src/controllers/messages.controller.ts import { Router } from 'express'; import { getContainer } from '../container'; import { asyncHandler } from '../middleware/async-handler'; import { validateBody } from '../middleware/validate'; import { SendMessageSchema, WelcomeMessageSchema, OrderConfirmationSchema } from '../types'; import type { Request, Response } from 'express'; const router = Router(); router.post('/send', validateBody(SendMessageSchema), asyncHandler(async (req: Request, res: Response) => { const { messagesService } = getContainer(); const result = await messagesService.sendMessage(req.body); res.status(200).json({ success: true, data: result }); })); router.post('/welcome', validateBody(WelcomeMessageSchema), asyncHandler(async (req: Request, res: Response) => { const { messagesService } = getContainer(); const result = await messagesService.sendWelcomeMessage(req.body); res.status(200).json({ success: true, data: result }); })); router.post('/order-confirmation', validateBody(OrderConfirmationSchema), asyncHandler(async (req: Request, res: Response) => { const { messagesService } = getContainer(); const result = await messagesService.sendOrderConfirmation(req.body); res.status(200).json({ success: true, data: result }); })); export { router as messagesRouter }; ``` ### Webhooks Controller ```typescript // src/controllers/webhooks.controller.ts import { Router } from 'express'; import { env } from '../config/env'; import { getContainer } from '../container'; import { asyncHandler } from '../middleware/async-handler'; import { WebhookEventSchema, ApiError } from '../types'; import type { Request, Response } from 'express'; const router = Router(); router.post('/sent', asyncHandler(async (req: Request, res: Response) => { const { sentService, logger } = getContainer(); const signature = req.headers['x-webhook-signature'] as string; if (!signature) throw new ApiError(401, 'Unauthorized', 'Missing webhook signature'); const isValid = sentService.verifyWebhookSignature(req.body, signature, env.SENT_DM_WEBHOOK_SECRET); if (!isValid) throw new ApiError(401, 'Unauthorized', 'Invalid webhook signature'); const rawEvent = sentService.constructWebhookEvent(req.body); const event = WebhookEventSchema.parse(rawEvent); logger.info({ eventType: event.type, messageId: event.data.id }, 'Processing webhook'); switch (event.type) { case 'message.status.updated': logger.info({ messageId: event.data.id, status: event.data.status }, 'Status updated'); break; case 'message.delivered': logger.info({ messageId: event.data.id }, 'Delivered'); break; case 'message.read': logger.info({ messageId: event.data.id }, 'Read'); break; case 'message.failed': logger.error({ messageId: event.data.id, error: event.data.error }, 'Failed'); break; default: logger.warn({ eventType: event.type }, 'Unhandled event type'); } res.json({ received: true }); })); export { router as webhooksRouter }; ``` ### Health Controller ```typescript // src/controllers/health.controller.ts import { Router } from 'express'; import { getContainer } from '../container'; import type { Request, Response } from 'express'; const router = Router(); interface HealthStatus { status: 'healthy' | 'unhealthy'; timestamp: string; uptime: number; version: string; services: { sentdm: 'connected' | 'disconnected' }; } router.get('/', async (_req: Request, res: Response) => { const { logger } = getContainer(); const health: HealthStatus = { status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), version: process.env.npm_package_version || '1.0.0', services: { sentdm: 'connected' }, }; logger.debug(health, 'Health check'); res.status(200).json(health); }); router.get('/ready', (_req: Request, res: Response) => res.status(200).json({ ready: true })); router.get('/live', (_req: Request, res: Response) => res.status(200).json({ alive: true })); export { router as healthRouter }; ``` ## App Configuration ```typescript // src/app.ts import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; import { env } from './config/env'; import { logger } from './config/logger'; import { requestLogger } from './middleware/request-logger'; import { errorHandler } from './middleware/error-handler'; import { messagesRouter } from './controllers/messages.controller'; import { webhooksRouter } from './controllers/webhooks.controller'; import { healthRouter } from './controllers/health.controller'; export function createApp(): express.Application { const app = express(); app.use(helmet()); app.use(cors({ origin: env.NODE_ENV === 'production' ? [/\.sentdm\.io$/] : ['http://localhost:3000', 'http://localhost:5173'], credentials: true, })); const limiter = rateLimit({ windowMs: env.RATE_LIMIT_WINDOW_MS, max: env.RATE_LIMIT_MAX_REQUESTS, standardHeaders: true, legacyHeaders: false, handler: (_req, res) => res.status(429).json({ error: { code: 'RateLimitExceeded', message: 'Too many requests' } }), }); app.use(limiter); const webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, skipSuccessfulRequests: true }); app.use(requestLogger); app.use('/api', express.json({ limit: '10mb' })); app.use('/webhooks', express.raw({ type: 'application/json' })); app.use('/health', healthRouter); app.use('/api/messages', messagesRouter); app.use('/webhooks', webhookLimiter, webhooksRouter); app.use((_req, res) => res.status(404).json({ error: { code: 'NotFound', message: 'Resource not found' } })); app.use(errorHandler); return app; } ``` ## Server Bootstrap ```typescript // src/server.ts import { createApp } from './app'; import { env } from './config/env'; import { logger } from './config/logger'; async function bootstrap(): Promise { const app = createApp(); const server = app.listen(env.PORT, () => { logger.info({ port: env.PORT, env: env.NODE_ENV }, '🚀 Server started'); }); const shutdown = (signal: string) => { logger.info({ signal }, 'Shutting down...'); server.close(() => { logger.info('Server closed'); process.exit(0); }); setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 10000); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('uncaughtException', (error) => { logger.fatal({ error }, 'Uncaught exception'); shutdown('uncaughtException'); }); process.on('unhandledRejection', (reason) => { logger.fatal({ reason }, 'Unhandled rejection'); shutdown('unhandledRejection'); }); } bootstrap(); ``` ## Testing ### Test Setup ```typescript // src/tests/setup.ts import { beforeEach, afterEach } from 'vitest'; import { resetContainer, setContainer } from '../container'; import type { Container } from '../container'; export function setupTestContainer(container: Partial = {}): void { const defaultContainer: Container = { logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {} } as Container['logger'], sentService: { sendMessage: vi.fn(), verifyWebhookSignature: vi.fn(), constructWebhookEvent: vi.fn() } as Container['sentService'], messagesService: { sendMessage: vi.fn(), sendWelcomeMessage: vi.fn(), sendOrderConfirmation: vi.fn() } as Container['messagesService'], ...container, }; setContainer(defaultContainer); } beforeEach(() => resetContainer()); afterEach(() => { resetContainer(); vi.clearAllMocks(); }); ``` ### Service Tests (Condensed) ```typescript // src/services/messages.service.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MessagesService } from './messages.service'; const mockSentService = { sendMessage: vi.fn() }; const mockLogger = { info: vi.fn(), error: vi.fn() }; describe('MessagesService', () => { let service: MessagesService; beforeEach(() => { service = new MessagesService(mockSentService as any, mockLogger as any); vi.clearAllMocks(); }); it('should send message and return response', async () => { mockSentService.sendMessage.mockResolvedValue([{ id: 'msg_123', status: 'pending', channel: 'whatsapp' }]); const result = await service.sendMessage({ phoneNumber: '+1234567890', templateId: 'template-123', templateName: 'welcome' }); expect(result).toEqual({ messageId: 'msg_123', status: 'pending', channels: ['whatsapp'] }); }); it('should send welcome with default name', async () => { mockSentService.sendMessage.mockResolvedValue([{ id: 'msg_456', status: 'queued', channel: 'whatsapp' }]); await service.sendWelcomeMessage({ phoneNumber: '+1234567890' }); expect(mockSentService.sendMessage).toHaveBeenCalledWith({ to: ['+1234567890'], template: { id: 'welcome-template-id', name: 'welcome', parameters: { name: 'Valued Customer' } }, channels: ['whatsapp'] }); }); }); ``` ### Integration Tests (Condensed) ```typescript // src/tests/messages.integration.spec.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import request from 'supertest'; import { createApp } from '../app'; import { setupTestContainer } from './setup'; describe('Messages API', () => { let app: ReturnType; beforeEach(() => { setupTestContainer({ messagesService: { sendMessage: vi.fn().mockResolvedValue({ messageId: 'msg_123', status: 'pending', channels: ['whatsapp'] }), sendWelcomeMessage: vi.fn().mockResolvedValue({ messageId: 'msg_456', status: 'queued', channels: ['whatsapp'] }), } as any, }); app = createApp(); }); it('POST /api/messages/send - success', async () => { const response = await request(app).post('/api/messages/send').send({ phoneNumber: '+1234567890', templateId: '550e8400-e29b-41d4-a716-446655440000', templateName: 'welcome', parameters: { name: 'John' } }); expect(response.status).toBe(200); expect(response.body.data.messageId).toBe('msg_123'); }); it('POST /api/messages/send - validation error', async () => { const response = await request(app).post('/api/messages/send').send({ phoneNumber: 'invalid', templateId: '550e8400-e29b-41d4-a716-446655440000', templateName: 'welcome' }); expect(response.status).toBe(400); expect(response.body.error.code).toBe('ValidationError'); }); }); ``` ## Environment Variables ```bash # .env NODE_ENV=development PORT=3000 LOG_LEVEL=debug SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 ``` ## Running the Application ```json // package.json { "scripts": { "dev": "nodemon --exec ts-node src/server.ts", "build": "tsc", "start": "node dist/server.js", "test": "vitest", "test:coverage": "vitest --coverage", "lint": "eslint src/**/*.ts", "typecheck": "tsc --noEmit" } } ``` ```typescript // tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true, "sourceMap": true, "moduleResolution": "node" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts"] } ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [TypeScript SDK reference](/sdks/typescript) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/integrations/nestjs.txt TITLE: NestJS Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/integrations/nestjs.txt Dependency injection, modules, and REST APIs with NestJS # NestJS Integration Complete NestJS integration with dependency injection, configuration management, and modular architecture. This example uses `@sentdm/sentdm` with NestJS patterns including modules, providers, services, and controllers. ## Project Structure Recommended directory structure for a NestJS project with Sent integration: ``` src/ ├── app.module.ts ├── main.ts ├── sent/ │ ├── sent.module.ts # Dynamic module with provider │ └── sent.types.ts # TypeScript interfaces ├── messages/ │ ├── messages.module.ts # Feature module │ ├── messages.controller.ts # REST endpoints │ ├── messages.service.ts # Business logic │ └── dto/ │ ├── send-message.dto.ts │ └── welcome-message.dto.ts ├── webhooks/ │ ├── webhooks.controller.ts # Webhook handlers │ └── webhooks.module.ts └── common/ └── filters/ └── sent-exception.filter.ts ``` ## Module Setup Create a dedicated Sent module with configurable options: ```typescript // sent/sent.module.ts import { Module, DynamicModule, Provider } from '@nestjs/common'; import SentDm from '@sentdm/sentdm'; export interface SentModuleOptions { apiKey: string; isGlobal?: boolean; } export const SENT_CLIENT = Symbol('SENT_CLIENT'); @Module({}) export class SentModule { static forRoot(options: SentModuleOptions): DynamicModule { const sentProvider: Provider = { provide: SENT_CLIENT, useFactory: () => { return new SentDm({ apiKey: options.apiKey, }); }, }; return { module: SentModule, providers: [sentProvider], exports: [sentProvider], global: options.isGlobal ?? false, }; } static forRootAsync(asyncOptions: { useFactory: (...args: any[]) => Promise | SentModuleOptions; inject?: any[]; isGlobal?: boolean; }): DynamicModule { const sentProvider: Provider = { provide: SENT_CLIENT, useFactory: async (...args: any[]) => { const options = await asyncOptions.useFactory(...args); return new SentDm({ apiKey: options.apiKey, }); }, inject: asyncOptions.inject || [], }; return { module: SentModule, providers: [sentProvider], exports: [sentProvider], global: asyncOptions.isGlobal ?? false, }; } } ``` ## Configuration with ConfigModule Integrate with `@nestjs/config` for environment-based configuration: ```typescript // app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { SentModule } from './sent/sent.module'; import { MessagesModule } from './messages/messages.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env'], }), SentModule.forRootAsync({ useFactory: (configService: ConfigService) => ({ apiKey: configService.getOrThrow('SENT_DM_API_KEY'), }), inject: [ConfigService], isGlobal: true, }), MessagesModule, ], }) export class AppModule {} ``` ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret ``` ## Service Layer Create a service that encapsulates Sent SDK operations: ```typescript // messages/messages.service.ts import { Injectable, Inject, Logger } from '@nestjs/common'; import SentDm from '@sentdm/sentdm'; import { SENT_CLIENT } from '../sent/sent.module'; export interface SendMessageDto { phoneNumber: string; templateId: string; templateName: string; parameters?: Record; channels?: string[]; } export interface MessageResponse { messageId: string; status: string; } @Injectable() export class MessagesService { private readonly logger = new Logger(MessagesService.name); constructor( @Inject(SENT_CLIENT) private readonly sentClient: SentDm, ) {} async sendMessage(dto: SendMessageDto): Promise { try { const response = await this.sentClient.messages.send({ to: [dto.phoneNumber], template: { id: dto.templateId, name: dto.templateName, parameters: dto.parameters || {}, }, channels: dto.channels, }); const message = response.data.messages[0]; this.logger.log(`Message sent: ${message.id} to ${dto.phoneNumber}`); return { messageId: message.id, status: message.status, }; } catch (error) { this.logger.error( `Failed to send message to ${dto.phoneNumber}`, error instanceof Error ? error.stack : undefined, ); throw error; } } async sendWelcomeMessage(phoneNumber: string, name: string): Promise { return this.sendMessage({ phoneNumber, templateId: 'welcome-template-id', templateName: 'welcome', parameters: { name }, channels: ['whatsapp'], }); } async sendOrderConfirmation( phoneNumber: string, orderNumber: string, total: string, ): Promise { return this.sendMessage({ phoneNumber, templateId: 'order-confirmation-id', templateName: 'order_confirmation', parameters: { order_number: orderNumber, total }, channels: ['sms', 'whatsapp'], }); } } ``` ## DTOs with Validation Use class-validator for input validation: ```typescript // messages/dto/send-message.dto.ts import { IsString, IsOptional, IsObject, IsArray, ArrayMinSize } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class SendMessageDto { @ApiProperty({ description: 'Phone number in E.164 format', example: '+1234567890' }) @IsString() phoneNumber: string; @ApiProperty({ description: 'Template ID', example: '7ba7b820-9dad-11d1-80b4-00c04fd430c8' }) @IsString() templateId: string; @ApiProperty({ description: 'Template name', example: 'welcome' }) @IsString() templateName: string; @ApiPropertyOptional({ description: 'Template parameters', example: { name: 'John' } }) @IsOptional() @IsObject() parameters?: Record; @ApiPropertyOptional({ description: 'Channels to use', example: ['whatsapp', 'sms'] }) @IsOptional() @IsArray() @ArrayMinSize(1) @IsString({ each: true }) channels?: string[]; } ``` ```typescript // messages/dto/welcome-message.dto.ts import { IsString, IsOptional } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class WelcomeMessageDto { @ApiProperty({ description: 'Phone number in E.164 format', example: '+1234567890' }) @IsString() phoneNumber: string; @ApiPropertyOptional({ description: 'Customer name', example: 'John Doe' }) @IsOptional() @IsString() name?: string; } ``` ## Controller REST API controller with proper error handling: ```typescript // messages/messages.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, UsePipes, ValidationPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { MessagesService, MessageResponse } from './messages.service'; import { SendMessageDto } from './dto/send-message.dto'; import { WelcomeMessageDto } from './dto/welcome-message.dto'; @ApiTags('Messages') @Controller('api/messages') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) export class MessagesController { constructor(private readonly messagesService: MessagesService) {} @Post('send') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Send a message using a template' }) @ApiResponse({ status: 200, description: 'Message sent successfully' }) @ApiResponse({ status: 400, description: 'Invalid request' }) @ApiResponse({ status: 401, description: 'Authentication failed' }) async sendMessage(@Body() dto: SendMessageDto): Promise { return this.messagesService.sendMessage(dto); } @Post('welcome') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Send a welcome message' }) @ApiResponse({ status: 200, description: 'Welcome message sent successfully' }) async sendWelcome(@Body() dto: WelcomeMessageDto): Promise { return this.messagesService.sendWelcomeMessage( dto.phoneNumber, dto.name || 'Valued Customer', ); } } ``` ```typescript // messages/messages.module.ts import { Module } from '@nestjs/common'; import { MessagesController } from './messages.controller'; import { MessagesService } from './messages.service'; @Module({ controllers: [MessagesController], providers: [MessagesService], exports: [MessagesService], }) export class MessagesModule {} ``` ## Exception Filter Global exception handling for Sent SDK errors: ```typescript // common/filters/sent-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger, } from '@nestjs/common'; import { Response } from 'express'; import SentDm from '@sentdm/sentdm'; @Catch(SentDm.APIError) export class SentExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(SentExceptionFilter.name); catch(exception: SentDm.APIError, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); this.logger.error( `Sent API Error: ${exception.name}`, JSON.stringify({ status: exception.status, message: exception.message, headers: exception.headers, }), ); const statusCode = this.mapErrorStatus(exception); response.status(statusCode).json({ error: exception.name, message: exception.message, statusCode, }); } private mapErrorStatus(error: SentDm.APIError): number { switch (error.status) { case 400: return HttpStatus.BAD_REQUEST; case 401: return HttpStatus.UNAUTHORIZED; case 403: return HttpStatus.FORBIDDEN; case 404: return HttpStatus.NOT_FOUND; case 422: return HttpStatus.UNPROCESSABLE_ENTITY; case 429: return HttpStatus.TOO_MANY_REQUESTS; default: return HttpStatus.INTERNAL_SERVER_ERROR; } } } ``` Apply the filter globally or at the controller level: ```typescript // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { SentExceptionFilter } from './common/filters/sent-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalFilters(new SentExceptionFilter()); await app.listen(3000); } bootstrap(); ``` ## Webhook Handler Handle incoming webhooks with signature verification: ```typescript // webhooks/webhooks.controller.ts import { Controller, Post, Headers, Body, UnauthorizedException, BadRequestException, Inject, RawBody, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import SentDm from '@sentdm/sentdm'; import { SENT_CLIENT } from '../sent/sent.module'; import { Logger } from '@nestjs/common'; interface WebhookEvent { type: string; data: { id: string; status?: string; error?: { message: string; }; }; } @ApiTags('Webhooks') @Controller('webhooks') export class WebhooksController { private readonly logger = new Logger(WebhooksController.name); constructor( @Inject(SENT_CLIENT) private readonly sentClient: SentDm, private readonly configService: ConfigService, ) {} @Post('sent') @ApiOperation({ summary: 'Handle Sent webhooks' }) async handleWebhook( @Headers('x-webhook-signature') signature: string, @RawBody() rawBody: Buffer, ): Promise<{ received: boolean }> { if (!signature) { throw new UnauthorizedException('Missing webhook signature'); } const webhookSecret = this.configService.get('SENT_DM_WEBHOOK_SECRET'); if (!webhookSecret) { throw new BadRequestException('Webhook secret not configured'); } // Verify signature (implementation depends on SDK capabilities) // For now, we parse and handle the event let event: WebhookEvent; try { event = JSON.parse(rawBody.toString()) as WebhookEvent; } catch { throw new BadRequestException('Invalid JSON payload'); } await this.handleEvent(event); return { received: true }; } private async handleEvent(event: WebhookEvent): Promise { this.logger.log(`Processing webhook event: ${event.type}`); switch (event.type) { case 'message.status.updated': this.logger.log(`Message ${event.data.id} status: ${event.data.status}`); // Update database, notify user, etc. break; case 'message.delivered': this.logger.log(`Message ${event.data.id} delivered`); break; case 'message.failed': this.logger.error( `Message ${event.data.id} failed: ${event.data.error?.message}`, ); // Handle failure - retry, notify, etc. break; default: this.logger.warn(`Unhandled event type: ${event.type}`); } } } ``` Enable raw body parsing for webhook signature verification: ```typescript // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { SentExceptionFilter } from './common/filters/sent-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, }); app.useGlobalFilters(new SentExceptionFilter()); await app.listen(3000); } bootstrap(); ``` ## Testing Unit testing with Jest and mocking: ```typescript // messages/messages.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { MessagesService } from './messages.service'; import { SENT_CLIENT } from '../sent/sent.module'; import SentDm from '@sentdm/sentdm'; const mockSentClient = { messages: { send: jest.fn(), }, }; describe('MessagesService', () => { let service: MessagesService; let sentClient: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ MessagesService, { provide: SENT_CLIENT, useValue: mockSentClient, }, ], }).compile(); service = module.get(MessagesService); sentClient = module.get(SENT_CLIENT); }); afterEach(() => { jest.clearAllMocks(); }); describe('sendMessage', () => { it('should send a message successfully', async () => { const mockResponse = { data: { messages: [ { id: 'msg_123', status: 'pending', }, ], }, }; mockSentClient.messages.send.mockResolvedValue(mockResponse); const result = await service.sendMessage({ phoneNumber: '+1234567890', templateId: 'welcome-template', templateName: 'welcome', parameters: { name: 'John' }, }); expect(result).toEqual({ messageId: 'msg_123', status: 'pending', }); expect(mockSentClient.messages.send).toHaveBeenCalledWith({ to: ['+1234567890'], template: { id: 'welcome-template', name: 'welcome', parameters: { name: 'John' }, }, channels: undefined, }); }); it('should throw error when API call fails', async () => { const apiError = new SentDm.APIError( 400, 'BadRequestError', 'Invalid phone number', {}, ); mockSentClient.messages.send.mockRejectedValue(apiError); await expect( service.sendMessage({ phoneNumber: 'invalid', templateId: 'welcome-template', templateName: 'welcome', }), ).rejects.toThrow(SentDm.APIError); }); }); describe('sendWelcomeMessage', () => { it('should send welcome message with correct parameters', async () => { const mockResponse = { data: { messages: [ { id: 'msg_456', status: 'queued', }, ], }, }; mockSentClient.messages.send.mockResolvedValue(mockResponse); const result = await service.sendWelcomeMessage('+1234567890', 'Jane'); expect(result.messageId).toBe('msg_456'); expect(mockSentClient.messages.send).toHaveBeenCalledWith( expect.objectContaining({ to: ['+1234567890'], template: expect.objectContaining({ name: 'welcome', parameters: { name: 'Jane' }, }), channels: ['whatsapp'], }), ); }); }); }); ``` ## Dependency Graph The recommended module structure for NestJS applications: ``` src/ ├── app.module.ts ├── main.ts ├── sent/ │ ├── sent.module.ts # Dynamic module with provider │ └── sent.types.ts # TypeScript interfaces ├── messages/ │ ├── messages.module.ts # Feature module │ ├── messages.controller.ts # REST endpoints │ ├── messages.service.ts # Business logic │ └── dto/ │ ├── send-message.dto.ts │ └── welcome-message.dto.ts ├── webhooks/ │ ├── webhooks.controller.ts # Webhook handlers │ └── webhooks.module.ts └── common/ └── filters/ └── sent-exception.filter.ts ``` ## Environment Variables ```bash # .env SENT_DM_API_KEY=your_api_key_here SENT_DM_WEBHOOK_SECRET=your_webhook_secret ``` ## Next Steps - Learn about [best practices](/sdks/best-practices) for production deployments - Set up [webhooks](/start/webhooks/getting-started) for delivery status updates - Explore the [TypeScript SDK reference](/sdks/typescript) for advanced features ================================================================================ SOURCE: https://docs.sent.dm/llms/sdks/typescript/integrations/nextjs.txt TITLE: Next.js Integration ================================================================================ URL: https://docs.sent.dm/llms/sdks/typescript/integrations/nextjs.txt Complete Next.js App Router integration with Server Actions, Route Handlers, Edge Runtime, and Server Components # Next.js Integration Complete Next.js 14+ App Router integration with Server Actions, Route Handlers, Edge Runtime, and type-safe validation using Zod. This example uses `@sentdm/sentdm` with Next.js App Router patterns including Server Components, Server Actions, and Route Handlers. ## Installation Install the required dependencies: ```bash npm install @sentdm/sentdm zod npm install -D @types/node ``` ```bash yarn add @sentdm/sentdm zod yarn add -D @types/node ``` ```bash pnpm add @sentdm/sentdm zod pnpm add -D @types/node ``` ```bash bun add @sentdm/sentdm zod ``` For rate limiting (optional): ```bash npm install @upstash/ratelimit @upstash/redis ``` ## Project Structure Recommended project structure for Next.js applications with Sent: ``` app/ ├── api/ │ ├── messages/ │ │ └── route.ts # Route Handler for messages │ └── webhooks/ │ └── sent/ │ └── route.ts # Webhook handler ├── actions/ │ └── messages.ts # Server Actions ├── components/ │ ├── message-form.tsx # Client Component form │ └── message-list.tsx # Server Component ├── lib/ │ ├── sent/ │ │ ├── client.ts # SDK client configuration │ │ └── schemas.ts # Zod validation schemas │ └── utils.ts # Helper functions └── page.tsx # Main page ``` ## SDK Client Configuration Create a centralized SDK client with proper configuration: ```typescript // lib/sent/client.ts import SentDm from '@sentdm/sentdm'; const apiKey = process.env.SENT_DM_API_KEY; if (!apiKey && process.env.NODE_ENV === 'production') { throw new Error('SENT_DM_API_KEY is required in production'); } export const sentClient = new SentDm({ apiKey: apiKey || 'test-key', maxRetries: 2, timeout: 30 * 1000, logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn', }); export function isSentError(error: unknown): error is SentDm.APIError { return error instanceof SentDm.APIError; } export function handleSentError(error: unknown) { if (isSentError(error)) { return { message: error.message, status: error.status, code: error.name }; } if (error instanceof Error) { return { message: error.message, status: 500, code: 'InternalError' }; } return { message: 'An unexpected error occurred', status: 500, code: 'UnknownError' }; } ``` ## Validation Schemas Define Zod schemas for type-safe validation: ```typescript // lib/sent/schemas.ts import { z } from 'zod'; export const phoneNumberSchema = z .string() .min(1, 'Phone number is required') .regex(/^\+[1-9]\d{1,14}$/, 'Phone number must be in E.164 format'); export const sendMessageSchema = z.object({ phoneNumber: phoneNumberSchema, templateId: z.string().uuid('Invalid template ID format'), templateName: z.string().min(1, 'Template name is required'), parameters: z.record(z.string()).optional(), channels: z.array(z.enum(['sms', 'whatsapp', 'email'])).optional(), testMode: z.boolean().optional(), }); export const batchMessageSchema = z.object({ recipients: z.array( z.object({ phoneNumber: phoneNumberSchema, parameters: z.record(z.string()).optional() }) ).min(1).max(100), templateId: z.string().uuid(), templateName: z.string(), channels: z.array(z.enum(['sms', 'whatsapp', 'email'])).optional(), }); export const webhookEventSchema = z.object({ type: z.enum(['message.status.updated', 'message.delivered', 'message.failed', 'message.read']), data: z.object({ id: z.string(), status: z.string().optional(), error: z.object({ message: z.string(), code: z.string().optional() }).optional(), }), }); export type SendMessageInput = z.infer; export type BatchMessageInput = z.infer; export type WebhookEvent = z.infer; export interface MessageResponse { messageId: string; status: string; } export interface MessageError { message: string; status: number; code?: string; field?: string; } export type SendMessageOutcome = | { success: true; data: MessageResponse } | { success: false; error: MessageError }; ``` ## Server Actions Server Actions for form submissions with progressive enhancement: ```typescript // app/actions/messages.ts 'use server'; import { revalidatePath } from 'next/cache'; import { sentClient, handleSentError } from '@/lib/sent/client'; import { sendMessageSchema } from '@/lib/sent/schemas'; import type { SendMessageOutcome } from '@/lib/sent/schemas'; export async function sendMessage(formData: FormData): Promise { try { const rawData = { phoneNumber: formData.get('phoneNumber'), templateId: formData.get('templateId'), templateName: formData.get('templateName'), parameters: JSON.parse((formData.get('parameters') as string) || '{}'), channels: formData.get('channels') ? JSON.parse(formData.get('channels') as string) : undefined, testMode: formData.get('testMode') === 'true', }; const validated = sendMessageSchema.safeParse(rawData); if (!validated.success) { const firstError = validated.error.errors[0]; return { success: false, error: { message: firstError.message, status: 400, code: 'ValidationError', field: firstError.path.join('.') } }; } const { phoneNumber, templateId, templateName, parameters, channels, testMode } = validated.data; const response = await sentClient.messages.send({ to: [phoneNumber], template: { id: templateId, name: templateName, parameters: parameters || {} }, channels, testMode, }); const message = response.data.messages[0]; revalidatePath('/messages'); return { success: true, data: { messageId: message.id, status: message.status } }; } catch (error) { console.error('Failed to send message:', error); return { success: false, error: handleSentError(error) }; } } export async function sendWelcomeMessage(phoneNumber: string, name: string): Promise { try { const response = await sentClient.messages.send({ to: [phoneNumber], template: { id: process.env.WELCOME_TEMPLATE_ID!, name: 'welcome', parameters: { name } }, channels: ['whatsapp'], }); revalidatePath('/contacts'); return { success: true, data: { messageId: response.data.messages[0].id, status: response.data.messages[0].status } }; } catch (error) { console.error('Failed to send welcome message:', error); return { success: false, error: handleSentError(error) }; } } ``` ## Route Handlers (API Routes) Create API routes with proper typing and validation: ```typescript // app/api/messages/route.ts import { NextRequest, NextResponse } from 'next/server'; import { sentClient, handleSentError } from '@/lib/sent/client'; import { sendMessageSchema, batchMessageSchema } from '@/lib/sent/schemas'; const corsHeaders = { 'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; export async function OPTIONS() { return NextResponse.json({}, { headers: corsHeaders }); } export async function POST(request: NextRequest) { try { const body = await request.json(); if (body.recipients) return handleBatchMessage(body, corsHeaders); return handleSingleMessage(body, corsHeaders); } catch (error) { const { message, status, code } = handleSentError(error); return NextResponse.json({ success: false, error: { message, code } }, { status, headers: corsHeaders }); } } async function handleSingleMessage(body: unknown, headers: Record) { const validated = sendMessageSchema.safeParse(body); if (!validated.success) { return NextResponse.json({ success: false, error: { message: 'Validation failed', code: 'ValidationError', details: validated.error.errors } }, { status: 400, headers }); } const { phoneNumber, templateId, templateName, parameters, channels, testMode } = validated.data; const response = await sentClient.messages.send({ to: [phoneNumber], template: { id: templateId, name: templateName, parameters: parameters || {} }, channels, testMode, }); const message = response.data.messages[0]; return NextResponse.json({ success: true, data: { messageId: message.id, status: message.status, recipient: phoneNumber } }, { headers }); } async function handleBatchMessage(body: unknown, headers: Record) { const validated = batchMessageSchema.safeParse(body); if (!validated.success) { return NextResponse.json({ success: false, error: { message: 'Validation failed', code: 'ValidationError', details: validated.error.errors } }, { status: 400, headers }); } const { recipients, templateId, templateName, channels } = validated.data; const results = await Promise.allSettled( recipients.map(async (recipient) => { const response = await sentClient.messages.send({ to: [recipient.phoneNumber], template: { id: templateId, name: templateName, parameters: recipient.parameters || {} }, channels, }); return { phoneNumber: recipient.phoneNumber, messageId: response.data.messages[0].id, status: response.data.messages[0].status }; }) ); const successful = results.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled').map((r) => r.value); const failed = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map((r, index) => ({ phoneNumber: recipients[index]?.phoneNumber, error: r.reason instanceof Error ? r.reason.message : 'Unknown error' })); return NextResponse.json({ success: true, data: { total: recipients.length, successful: successful.length, failed: failed.length, results: successful, errors: failed } }, { headers }); } ``` ## Server Components Fetch and display data in Server Components: ```typescript // app/components/message-list.tsx import { sentClient } from '@/lib/sent/client'; import { unstable_cache } from 'next/cache'; const getTemplates = unstable_cache(async () => { const response = await sentClient.templates.list(); return response.data; }, ['templates'], { revalidate: 300, tags: ['templates'] }); export default async function MessageList() { let templates; try { templates = await getTemplates(); } catch (error) { console.error('Failed to load templates:', error); return

Failed to load templates. Please try again later.

; } return (

Available Templates

{templates.map((template) => (

{template.name}

{template.status}

ID: {template.id}

))}
); } ``` ## Client Components Interactive form with Server Actions: ```typescript // app/components/message-form.tsx 'use client'; import { useState } from 'react'; import { sendMessage } from '../actions/messages'; import type { SendMessageOutcome } from '@/lib/sent/schemas'; interface MessageFormProps { templates: Array<{ id: string; name: string; status: string }>; } export default function MessageForm({ templates }: MessageFormProps) { const [result, setResult] = useState(null); const [isPending, setIsPending] = useState(false); async function handleSubmit(formData: FormData) { setIsPending(true); setResult(null); try { const outcome = await sendMessage(formData); setResult(outcome); } finally { setIsPending(false); } } return (

Format: +1234567890 (E.164)