agents/openai.yaml
interface:
display_name: "Sent Integration Starter"
short_description: "Stand up a production-ready Sent integration"
default_prompt: "Use $sent-integration-starter to review how my codebase should integrate Sent and what to harden before going live."
references/errors-and-limits.md
# Error catalog, retries, idempotency, and rate limits
## Table of contents
- [Response envelope](#response-envelope)
- [Retry classification by family](#retry-classification-by-family)
- [AUTH codes](#auth-codes)
- [VALIDATION codes](#validation-codes)
- [RESOURCE codes](#resource-codes)
- [BUSINESS codes](#business-codes)
- [CONFLICT, SERVICE, and INTERNAL codes](#conflict-service-and-internal-codes)
- [Codes that behave differently on send](#codes-that-behave-differently-on-send)
- [Idempotency semantics](#idempotency-semantics)
- [Rate limits and pacing](#rate-limits-and-pacing)
- [Sandbox semantics](#sandbox-semantics)
- [Ambiguous send recovery](#ambiguous-send-recovery)
## Response envelope
Every response uses one shape:
```json
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_004",
"message": "Request validation failed",
"details": { "to": ["'to' must contain at least one recipient"] },
"doc_url": "https://docs.sent.dm/reference/api/error-catalog"
},
"meta": {
"request_id": "req_7X9zKp2jDw",
"timestamp": "2026-03-14T09:21:44Z",
"version": "v3"
}
}
```
Branch on `error.code`, never on `error.message`. Read `error.details` for field-level validation feedback and log `meta.request_id` on every response so support can correlate.
## Retry classification by family
| Family | Count | Default handling |
| --- | --- | --- |
| `AUTH_` | 6 | Terminal. Stop immediately; do not loop |
| `VALIDATION_` | 8 | Terminal. Fix the request |
| `RESOURCE_` | 14 | Terminal; reconcile `RESOURCE_007` with the existing resource |
| `BUSINESS_` | 11 | Mostly terminal; `BUSINESS_002` backs off |
| `CONFLICT_` | 1 | Retry once after a short pause |
| `SERVICE_` | 1 | Retry with backoff |
| `INTERNAL_` | 5 | Retry with backoff |
The catalog contains 46 codes in total. Authentication failures deserve special care: ten consecutive failures lock the presented credential with a `429` and escalating lockout windows from one to sixty minutes, so a retry loop against a bad key extends its own outage. Stop and alert instead.
## AUTH codes
| Code | HTTP | Title | Retry |
| --- | --- | --- | --- |
| `AUTH_001` | 401 | User is not authenticated | never |
| `AUTH_002` | 401 | Invalid or missing API key | never |
| `AUTH_004` | 403 | Insufficient permissions | never |
| `AUTH_005` | 403 | Account not yet activated | never |
| `AUTH_006` | 403 | KYC verification not complete | never |
| `AUTH_007` | 403 | Channel setup not complete | never |
`AUTH_004` is also what a profile-scoped key receives when it sends `x-profile-id`. `AUTH_005`, `AUTH_006`, and `AUTH_007` are onboarding states rather than credential problems, so surface them to an operator instead of retrying.
## VALIDATION codes
| Code | HTTP | Title | Retry |
| --- | --- | --- | --- |
| `VALIDATION_001` | 400 | Request validation failed | never |
| `VALIDATION_002` | 400 | Invalid phone number format | never |
| `VALIDATION_003` | 400 | Invalid GUID format | never |
| `VALIDATION_004` | 400 | Required field is missing | never |
| `VALIDATION_005` | 400 | Field value out of valid range | never |
| `VALIDATION_006` | 400 | Invalid enum value | never |
| `VALIDATION_007` | 400 | Invalid Idempotency-Key format | never |
| `VALIDATION_008` | 400 | Invalid template variable value | never |
`VALIDATION_002` is prevented by normalizing recipients to E.164 before the call. `VALIDATION_006` is what an unsupported `channel` value returns. `VALIDATION_008` covers several distinct template-variable problems, so read the message rather than assuming one cause.
## RESOURCE codes
| Code | HTTP | Title | Retry |
| --- | --- | --- | --- |
| `RESOURCE_001` | 404 | Contact not found | never |
| `RESOURCE_002` | 404 | Template not found | never |
| `RESOURCE_003` | 404 | Message not found | never |
| `RESOURCE_004` | 404 | Customer not found | never |
| `RESOURCE_005` | 404 | Organization not found | never |
| `RESOURCE_006` | 404 | User not found | never |
| `RESOURCE_007` | 409 | Resource already exists | do not retry blindly |
| `RESOURCE_008` | 404 | Webhook not found | never |
| `RESOURCE_009` | 404 | Brand not found | never |
| `RESOURCE_010` | 404 | Campaign not found | never |
| `RESOURCE_011` | 404 | Batch not found | never |
| `RESOURCE_012` | 404 | Phone number not found | never |
| `RESOURCE_013` | 404 | Resource not found | never |
| `RESOURCE_014` | 404 | Profile not found | never |
`RESOURCE_014` also occurs when an organization passes its own identifier as a `profileId`, which must be a child profile. `RESOURCE_007` is the duplicate-creation signal, most visibly when inviting a user who already has access; read the existing resource and decide whether the requested state is already satisfied.
## BUSINESS codes
| Code | HTTP | Title | Retry |
| --- | --- | --- | --- |
| `BUSINESS_001` | 400 | Cannot modify inherited contact | never |
| `BUSINESS_002` | 429 | Rate limit exceeded | backoff |
| `BUSINESS_003` | 402 | Insufficient account balance | never |
| `BUSINESS_004` | 400 | Contact has opted out | never |
| `BUSINESS_005` | 400 | Template not approved | never |
| `BUSINESS_006` | 400 | Message cannot be modified in current state | never |
| `BUSINESS_007` | 400 | Channel not available | never |
| `BUSINESS_008` | 400 | Operation would exceed quota | never |
| `BUSINESS_010` | 400 | Webhook is inactive | never |
| `BUSINESS_012` | 400 | Template is not active on the requested channel | never |
| `BUSINESS_014` | 403 | Account is suspended | never |
`BUSINESS_001` is the inheritance boundary: a profile that inherits contacts cannot modify them. `BUSINESS_010` explains why a test delivery to a disabled webhook fails; re-enable it with `PATCH /v3/webhooks/{id}/toggle-status` or from the dashboard after fixing the receiver.
## CONFLICT, SERVICE, and INTERNAL codes
| Code | HTTP | Title | Retry |
| --- | --- | --- | --- |
| `CONFLICT_001` | 409 | Concurrent idempotent request | after delay |
| `SERVICE_001` | 503 | Cache service temporarily unavailable | backoff |
| `INTERNAL_001` | 500 | Unexpected internal server error | backoff |
| `INTERNAL_002` | 500 | Database operation failed | backoff |
| `INTERNAL_003` | 500 | External service error | backoff |
| `INTERNAL_004` | 504 | Timeout waiting for operation | backoff |
| `INTERNAL_005` | 503 | Service temporarily unavailable | backoff |
`SERVICE_001` is a deliberate safety response: the idempotency cache was unavailable, so the API refused to execute rather than risk a duplicate. Retrying the same request with the same key is correct.
## Codes that behave differently on send
Two documented request-level codes do not reject `POST /v3/messages`. Insufficient balance (`BUSINESS_003`, 402) and an opted-out contact (`BUSINESS_004`, 400) are catalogued as errors, but on send the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED` respectively. Client code that only inspects HTTP status will believe those sends succeeded.
The operational consequence is that balance and consent problems appear in delivery data rather than in error handling. Monitor blocked and filtered rates as first-class metrics alongside `4xx` and `5xx` counts.
Sent also records internal reason codes on a message for consent blocks, route denials, no-route-matched, and invalid template parameters. These are never returned in API responses or webhook payloads, so diagnosis uses the terminal status plus the channel value plus `GET /v3/messages/{id}/activities`.
## Idempotency semantics
`Idempotency-Key` applies to POST, PUT, and PATCH on `/v3/*` and is ignored on GET and DELETE. Values are 1 to 255 characters of `[A-Za-z0-9_-]`.
| Situation | Behavior |
| --- | --- |
| First successful request | Response cached for 24 hours per key per customer |
| Replay of a cached key | Cached body returned with `Idempotent-Replayed: true` and `X-Original-Request-Id` |
| Response larger than 5 MB | Not cached; a duplicate re-executes |
| Duplicate arrives while the original is in flight | Waits up to five seconds, then fails `409 CONFLICT_001` |
| Idempotency cache unavailable | `503 SERVICE_001`; the request was not executed |
Derive keys deterministically from your own domain objects — an order id plus a notification type, for example — rather than generating a random value per attempt, so that a retry after a network timeout collides with the original instead of creating a second send. Because caching is per customer, the same key used by two different customers is two independent operations.
## Rate limits and pacing
| Tier | Limit | Window | Applies to |
| --- | --- | --- | --- |
| Standard | 200 requests/minute | Sliding 60 seconds | Everything not listed below |
| Sensitive | 10 requests/minute | Fixed window | `POST /v3/webhooks/{id}/rotate-secret`, `POST /v3/webhooks/{id}/test` |
`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` are present **only** on `429` responses. There is no way to read remaining quota preemptively, so pacing must be a design decision rather than an adaptive reaction.
For bulk work, batch up to 1,000 recipients per `POST /v3/messages` and pace at roughly one request per second, which keeps a large campaign inside the standard budget while leaving headroom for transactional traffic. Note that batching multiplies with channels: 1,000 recipients on two channels is 2,000 messages and 2,000 charges from a single request.
Rate-limit exposure follows the credential. A profile-scoped key has its own pool; an organization key acting through `x-profile-id` draws on the organization pool shared by every profile.
## Sandbox semantics
`"sandbox": true` runs authentication and validation and then stops. Nothing is persisted, queued, dispatched to a provider, or charged, and resource lookups do not occur — so a sandbox request will not tell you whether a template id exists. Malformed requests still return real `400` and `422` responses, which is what makes sandbox valuable in continuous integration.
The exception worth memorizing: `DELETE /v3/webhooks/{id}` ignores the flag and always deletes. Never use sandbox as a general dry-run guard for destructive calls.
## Ambiguous send recovery
When a send times out or the connection drops before a response arrives, the request may or may not have been accepted. Never blind-retry.
1. If the original carried an `Idempotency-Key`, retry with the **same** key. A cached success returns the original response with `Idempotent-Replayed: true`; a `409 CONFLICT_001` means the original is still in flight, so pause and retry once.
2. If no key was sent, search your own request and response records for a returned `message_id`. Sent exposes no reliable lookup by idempotency key or recipient that can prove an ambiguous request did not execute.
3. Escalate ambiguous no-key cases for an explicit duplicate-risk decision. Only send again when your application has sufficient evidence that nothing was accepted, and attach an idempotency key this time.
The same discipline applies to profile provisioning: a deterministic key derived from your provisioning record prevents a timeout from creating a second profile.
references/sdk-and-frameworks.md
# SDK selection and framework wiring
## Table of contents
- [Package matrix](#package-matrix)
- [Client construction per language](#client-construction-per-language)
- [Configuration and environment variables](#configuration-and-environment-variables)
- [Framework wiring](#framework-wiring)
- [Background processing per ecosystem](#background-processing-per-ecosystem)
- [Multi-tenant credential patterns](#multi-tenant-credential-patterns)
- [Testing and mocking](#testing-and-mocking)
- [Deployment notes](#deployment-notes)
## Package matrix
| Language | Package | Install | Minimum runtime |
| --- | --- | --- | --- |
| TypeScript | `@sentdm/sentdm` | `npm install @sentdm/sentdm` | Node with ESM or CJS |
| Python | `sentdm` (imports as `sent_dm`) | `pip install sentdm` | Python 3.9 |
| Go | `github.com/sentdm/sent-dm-go` | `go get github.com/sentdm/sent-dm-go` | Go 1.22 |
| Java | `dm.sent:sent-java` | Maven or Gradle dependency | Java 8 |
| C# | `Sentdm` | `dotnet add package Sentdm` | .NET Standard 2.0 |
| PHP | `sentdm/sent-dm-php` | `composer require sentdm/sent-dm-php` | PHP 8.1 |
| Ruby | `sentdm` | `gem install sentdm` or Bundler | Ruby 3.2 |
The distribution name and the import name differ in Python (`sentdm` installs, `sent_dm` imports) and the Ruby send method is `messages.send_` with a trailing underscore because `send` is reserved. Both are common first-hour errors.
No SDK ships a webhook signature verifier in any language. That code is always application-owned.
## Client construction per language
```typescript
import SentDm from '@sentdm/sentdm';
// Reads SENT_DM_API_KEY. Options: apiKey, baseUrl, maxRetries, timeout, logLevel.
export const sent = new SentDm({ maxRetries: 3, timeout: 30_000 });
const response = await sent.messages.send({
to: ['+14155551234'],
template: { name: 'order_confirmation', parameters: { order_id: '12345' } },
});
```
```python
from sent_dm import Sent, AsyncSent
client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY
async_client = AsyncSent()
response = client.messages.send(
to=["+14155551234"],
template={"name": "order_confirmation", "parameters": {"order_id": "12345"}},
)
```
```go
client := sentdm.NewClient() // or option.WithAPIKey(...)
response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{
To: []string{"+14155551234"},
})
```
```java
SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey
MessageSendResponse response = client.messages().send(params);
```
```csharp
using Sentdm;
SentClient client = new(); // reads SENT_DM_API_KEY
var response = await client.Messages.Send(body);
```
```php
use SentDm\Client;
$client = new Client($_ENV['SENT_DM_API_KEY']); // key is an explicit constructor argument
$result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']);
```
```ruby
require "sentdm"
client = Sentdm::Client.new # reads SENT_DM_API_KEY
client.messages.send_(to: ["+14155551234"], template: { name: "order_confirmation" })
```
Java and C# expose both synchronous and asynchronous clients; Python offers `Sent` and `AsyncSent`; TypeScript and C# are promise- or task-based only; Go and PHP and Ruby are synchronous, with Go carrying a `context.Context` on every call.
## Configuration and environment variables
| Variable | Purpose | Read automatically |
| --- | --- | --- |
| `SENT_DM_API_KEY` | REST credential sent as `x-api-key` | Yes, in every SDK except PHP |
| `SENT_DM_WEBHOOK_SECRET` | `whsec_`-prefixed webhook signing secret | No; application code reads it |
| `SENT_BASE_URL` | Override the API base URL | Java and C# read it; others take a constructor option |
Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`. Both name sets appear in official material; standardize new code on the `SENT_DM_` names because the SDK defaults use them, and accept the shorter names as aliases when adopting existing code.
For a single-account service, validate the server-managed key at startup with the ecosystem's schema tooling — `zod` in Node, `pydantic-settings` in Python, `@nestjs/config`, `IOptions` with `[Required]` in .NET — so a missing key fails the deployment rather than the first customer send. For a multi-tenant proxy, validate non-secret configuration at startup and reject each request whose resolved credential is absent or malformed.
## Framework wiring
| Framework | Client placement | Webhook raw body |
| --- | --- | --- |
| Next.js | Shared module such as `lib/sent/client.ts` | `await request.text()`; keep the route on the Node runtime |
| Express | Module singleton | `express.raw({ type: 'application/json' })` scoped to the webhook path |
| NestJS | Provider in a `SentModule` | `req.rawBody` with `NestFactory.create(AppModule, { rawBody: true })` |
| FastAPI | Client built in the lifespan, injected as a dependency | `await request.body()` |
| Django | `@lru_cache` factory in a `client.py` | `request.body` |
| Flask | Cached on the app or request context | `request.get_data()` |
| Gin / Echo | Constructed in `main`, passed to handlers | `io.ReadAll(c.Request.Body)` |
| Spring Boot | `@Bean` in a configuration class | `@RequestBody String payload` |
| Laravel | Singleton in the service container | `$request->getContent()` in middleware |
| Symfony | Autowired service | `$request->getContent()` |
| Rails | Memoized in an initializer | `request.body.read` then `request.body.rewind` |
| Sinatra | Memoized module method | `request.body.read` then `request.body.rewind` |
| ASP.NET Core | Singleton via dependency injection | `new StreamReader(request.Body).ReadToEndAsync()` |
The recurring defect is a global JSON body parser that destroys the byte-exact body needed for signature verification. Scope the parser away from the webhook path, or read the raw bytes before any parsing occurs.
A minimal integration is four files regardless of stack: a client module, an outbound send route, an inbound webhook route, and a signature-verification helper.
## Background processing per ecosystem
Webhook handlers must acknowledge with `200` and then work asynchronously, because ten consecutive failed deliveries disable the endpoint and a slow handler manufactures those failures.
| Ecosystem | Mechanism |
| --- | --- |
| Node | BullMQ or an equivalent durable queue |
| Python | Celery or another durable queue; reserve FastAPI `BackgroundTasks` for non-critical local work |
| Go | A bounded worker pool or a job queue |
| Java | `@Async` with a `ThreadPoolTaskExecutor`, or a broker |
| PHP | Laravel queued jobs, Symfony Messenger |
| Ruby | ActiveJob or Sidekiq |
| .NET | A `BackgroundService` consuming a channel or queue |
Route bulk campaign traffic to a queue separate from transactional sends so a large campaign cannot starve time-sensitive messages, and set worker concurrency or a task rate limit that respects the 200-requests-per-minute budget.
## Multi-tenant credential patterns
Two patterns exist, and mixing them causes confusing `403` responses.
A **profile-scoped key** is confined to one profile, has its own rate-limit pool, and must not send `x-profile-id` — doing so returns `403`. Prefer it for runtime send paths so a leaked key affects one tenant.
An **organization key with `x-profile-id`** reaches permitted child profiles but draws on the organization's shared rate-limit pool, so one noisy tenant consumes everyone's quota. Prefer it for control-plane work such as provisioning.
When each tenant supplies its own key, resolve it for the request, construct the client with that credential, and discard both afterward. Do not retain tenant credentials in a client cache merely to preserve connection pooling; isolation and rotation correctness take priority. Queued work must resolve the authorized tenant credential just in time from a secret store rather than embedding it in the job payload. Never place a key in a browser, mobile app, or any client the organization does not control, and keep separate keys per environment. `x-sender-id` is legacy v1 and v2 terminology with no role in v3.
## Testing and mocking
Use `"sandbox": true` for integration tests: authentication and validation still run, so a malformed request still returns `400` or `422`, but nothing is written, queued, charged, or dispatched to a provider. It is the right default in continuous integration.
For unit tests, mock at the SDK boundary — `jest.fn()` on `messages.send`, a NestJS testing module override, a substituted `ISentClient` in .NET — and assert on the request payload rather than on transport behavior. For the receiver, generate valid headers locally with the webhook skill's signing script so tests cover the signature path without contacting Sent.
Two notes on live verification. `POST /v3/webhooks/{id}/test` delivers exactly once with no retry, so re-run it after each fix. And `DELETE /v3/webhooks/{id}` ignores `sandbox` and always deletes, so never treat the flag as a dry-run guard for deletion.
## Deployment notes
Keep webhook routes on runtimes that expose Node-style crypto and raw bodies rather than on edge runtimes. Close the HTTP server gracefully on `SIGTERM` so in-flight deliveries finish instead of failing and triggering retries. Ensure load balancer idle timeouts exceed the configured `timeout_seconds`, and keep container clocks NTP-synchronized so the 300-second replay window does not reject valid traffic. Keep the route outside user-auth middleware. If abuse controls are required, make them signature-aware and capacity-safe rather than placing a generic limiter in front of verification and manufacturing the failures that lead to auto-disable.
scripts/preflight.py
#!/usr/bin/env python3
"""Offline preflight checks for a Sent v3 integration.
Validates the things that break integrations before any network call is made:
recipient formatting, send-payload shape, channel-array intent, idempotency-key
format, batch sizing against the documented pacing budget, and the retry
classification of an error code.
Usage
-----
Run the built-in synthetic fixtures::
python3 preflight.py --self-test
Check a send payload written to a file::
python3 preflight.py --payload-file send.json
Classify an error code for retry behavior::
python3 preflight.py --classify-error 409:CONFLICT_001
Exit codes: 0 all checks passed, 1 one or more findings, 3 usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
E164 = re.compile(r"^\+[1-9]\d{1,14}$")
IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9_-]{1,255}$")
VALID_CHANNELS = {"sent", "sms", "whatsapp", "rcs"}
MAX_RECIPIENTS_PER_REQUEST = 1000
STANDARD_RATE_LIMIT_PER_MINUTE = 200
SENSITIVE_RATE_LIMIT_PER_MINUTE = 10
TERMINAL_FAMILIES = {"AUTH", "VALIDATION", "RESOURCE"}
RETRYABLE_FAMILIES = {"SERVICE", "INTERNAL"}
EXIT_OK = 0
EXIT_FINDINGS = 1
EXIT_USAGE = 3
def check_recipients(recipients: object) -> list[str]:
"""Validate the `to` array."""
findings: list[str] = []
if not isinstance(recipients, list) or not recipients:
return ["'to' must be a non-empty array of E.164 phone numbers"]
for value in recipients:
if not isinstance(value, str) or not E164.match(value):
findings.append(f"recipient {value!r} is not E.164 (leading '+', country code, digits only)")
if len(recipients) > MAX_RECIPIENTS_PER_REQUEST:
findings.append(
f"{len(recipients)} recipients exceeds the {MAX_RECIPIENTS_PER_REQUEST}-recipient per-request limit"
)
return findings
def check_channels(channels: object) -> list[str]:
"""Validate the `channel` array and flag broadcast intent."""
if channels is None:
return []
findings: list[str] = []
if not isinstance(channels, list):
return ["'channel' must be an array when present"]
for value in channels:
if value not in VALID_CHANNELS:
findings.append(f"channel {value!r} is invalid; allowed values are {sorted(VALID_CHANNELS)}")
explicit = [value for value in channels if value != "sent"]
if len(explicit) > 1:
findings.append(
"multiple explicit channels broadcast rather than fall back: one message and one charge is created "
"per (recipient, channel) pair. Omit 'channel' or use ['sent'] for automatic routing with reroute"
)
if "sent" in channels and len(channels) > 1:
findings.append("'sent' combined with an explicit channel is ambiguous; use one or the other")
return findings
def check_content(payload: dict) -> list[str]:
"""Validate that exactly one content source is present."""
has_template = isinstance(payload.get("template"), dict)
has_text = isinstance(payload.get("text"), str) and payload["text"].strip() != ""
if has_template and has_text:
return ["provide either 'template' or 'text', not both"]
if not has_template and not has_text:
return ["provide 'template' or 'text' as the message content"]
if has_template:
template = payload["template"]
if not template.get("id") and not template.get("name"):
return ["'template' requires 'id' or 'name'"]
if template.get("id") and template.get("name"):
return ["'template.id' and 'template.name' are mutually exclusive"]
parameters = template.get("parameters")
if parameters is not None and not isinstance(parameters, dict):
return ["'template.parameters' must be an object of string values"]
if isinstance(parameters, dict) and any(not isinstance(value, str) for value in parameters.values()):
return ["every 'template.parameters' value must be a string"]
return []
def check_idempotency_key(key: object) -> list[str]:
"""Validate an Idempotency-Key header value."""
if key is None:
return ["no Idempotency-Key supplied; a timeout retry can produce a duplicate send"]
if not isinstance(key, str) or not IDEMPOTENCY_KEY.match(key):
return ["Idempotency-Key must be 1-255 characters of letters, digits, hyphens, or underscores"]
return []
def estimate_batches(recipient_count: int, channel_count: int = 1) -> dict[str, int]:
"""Return message and request estimates for a bulk send."""
channel_count = max(1, channel_count)
messages = recipient_count * channel_count
requests = -(-recipient_count // MAX_RECIPIENTS_PER_REQUEST)
minutes = -(-requests // STANDARD_RATE_LIMIT_PER_MINUTE)
return {
"messages_created": messages,
"requests_required": requests,
"minimum_minutes_at_rate_limit": minutes,
}
def classify_error(status: int, code: str) -> tuple[str, str]:
"""Return (classification, guidance) for a Sent error response."""
family = code.split("_", 1)[0].upper() if code else ""
if status == 429:
return "retry", "honor Retry-After, then use jittered exponential backoff; stop if the credential is locked"
if code.upper() == "CONFLICT_001":
return "retry-once", "a concurrent duplicate is in flight; pause, then retry the same Idempotency-Key once"
if code.upper() == "SERVICE_001":
return "retry", "the idempotency store was unavailable and the request was deliberately not executed"
if family in RETRYABLE_FAMILIES or 500 <= status < 600:
return "retry", "exponential backoff with jitter and a bounded ceiling"
if family == "AUTH":
return "terminal", "stop immediately; ten consecutive auth failures lock the credential with escalating lockout"
if family in TERMINAL_FAMILIES:
return "terminal", "fix the request or the referenced resource; retrying reproduces the same result"
if family == "BUSINESS":
return "conditional", (
"an account or policy precondition; on POST /v3/messages the send is accepted with 202 and the "
"affected messages finalize as BLOCKED or FILTERED, so resolve the condition before resending"
)
return "unknown", "treat as terminal until classified; log meta.request_id and inspect error.doc_url"
def check_payload(payload: dict, idempotency_key: str | None = None) -> list[str]:
"""Run every payload check and return the accumulated findings."""
findings: list[str] = []
findings.extend(check_recipients(payload.get("to")))
findings.extend(check_channels(payload.get("channel")))
findings.extend(check_content(payload))
findings.extend(check_idempotency_key(idempotency_key))
return findings
def _self_test() -> int:
failures: list[str] = []
good = {
"to": ["+14155551234"],
"template": {"name": "order_confirmation", "parameters": {"order_id": "12345"}},
}
if check_payload(good, "order-12345-confirmation"):
failures.append("a well-formed payload with an idempotency key must produce no findings")
if not check_recipients(["4155551234"]):
failures.append("a non-E.164 recipient must be flagged")
if not check_recipients([]):
failures.append("an empty recipient list must be flagged")
if not check_recipients(["+1415555%s" % "1" * 15]):
failures.append("an over-long number must be flagged")
broadcast = check_channels(["whatsapp", "sms"])
if not any("broadcast" in finding for finding in broadcast):
failures.append("a multi-channel array must be flagged as broadcast, not fallback")
if check_channels(["sent"]) or check_channels(None):
failures.append("automatic routing must produce no channel findings")
if not check_channels(["telegram"]):
failures.append("an unsupported channel value must be flagged")
if not check_content({"to": ["+14155551234"]}):
failures.append("missing content must be flagged")
if not check_content({"template": {"id": "x", "name": "y"}}):
failures.append("template id and name together must be flagged")
if not check_content({"template": {"name": "t"}, "text": "hello"}):
failures.append("template and text together must be flagged")
if not check_content({"template": {"name": "t", "parameters": {"count": 2}}}):
failures.append("non-string template parameter values must be flagged")
if not check_idempotency_key(None):
failures.append("a missing idempotency key must be flagged")
if not check_idempotency_key("bad key!"):
failures.append("an invalid idempotency key must be flagged")
estimate = estimate_batches(2500, 2)
if estimate != {"messages_created": 5000, "requests_required": 3, "minimum_minutes_at_rate_limit": 1}:
failures.append(f"batch estimation drifted: {estimate}")
expectations = {
(429, "BUSINESS_009"): "retry",
(409, "CONFLICT_001"): "retry-once",
(503, "SERVICE_001"): "retry",
(401, "AUTH_002"): "terminal",
(400, "VALIDATION_004"): "terminal",
(404, "RESOURCE_001"): "terminal",
(500, "INTERNAL_001"): "retry",
(402, "BUSINESS_003"): "conditional",
}
for (status, code), expected in expectations.items():
actual, _ = classify_error(status, code)
if actual != expected:
failures.append(f"{status} {code} classified as {actual}, expected {expected}")
if SENSITIVE_RATE_LIMIT_PER_MINUTE >= STANDARD_RATE_LIMIT_PER_MINUTE:
failures.append("the sensitive tier must be lower than the standard tier")
for failure in failures:
print(f"FAIL: {failure}", file=sys.stderr)
if failures:
return EXIT_FINDINGS
print("preflight self-test passed: 20 checks")
return EXIT_OK
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Offline preflight checks for a Sent v3 integration.")
parser.add_argument("--self-test", action="store_true", help="run synthetic fixtures and exit")
parser.add_argument("--payload-file", help="path to a JSON send payload to check")
parser.add_argument("--idempotency-key", help="the Idempotency-Key that will be sent with the payload")
parser.add_argument("--estimate", type=int, metavar="RECIPIENTS", help="estimate messages, requests, and pacing")
parser.add_argument("--channels", type=int, default=1, help="number of channels used with --estimate")
parser.add_argument("--classify-error", metavar="STATUS:CODE", help="classify an error response for retry")
args = parser.parse_args(argv)
if args.self_test:
return _self_test()
if args.classify_error:
try:
status_text, _, code = args.classify_error.partition(":")
classification, guidance = classify_error(int(status_text), code)
except ValueError:
print("error: --classify-error expects STATUS:CODE, for example 429:BUSINESS_009", file=sys.stderr)
return EXIT_USAGE
print(f"{args.classify_error} -> {classification}: {guidance}")
return EXIT_OK
if args.estimate is not None:
for key, value in estimate_batches(args.estimate, args.channels).items():
print(f"{key}: {value}")
return EXIT_OK
if not args.payload_file:
parser.error("provide --payload-file, --estimate, --classify-error, or --self-test")
try:
with open(args.payload_file, encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: could not read payload: {exc}", file=sys.stderr)
return EXIT_USAGE
if not isinstance(payload, dict):
print("error: payload must be a JSON object", file=sys.stderr)
return EXIT_USAGE
findings = check_payload(payload, args.idempotency_key)
if payload.get("sandbox") is True:
print("note: sandbox is true, so this request validates and authenticates without executing")
if not findings:
print("payload passed all preflight checks")
return EXIT_OK
for finding in findings:
print(f"- {finding}")
return EXIT_FINDINGS
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md
---
name: sent-integration-starter
description: Stands up a production-ready Sent v3 integration in an existing codebase — SDK selection and client construction, x-api-key configuration, idempotent sends, retry and rate-limit handling, the 46-code error catalog, sandbox verification, and a verified webhook receiver. Use when adding Sent to an app for the first time, choosing an SDK or framework wiring, handling 429 or 409 responses, deciding what to log, or hardening an integration before launch.
---
# Sent Integration Starter
Bring up a Sent integration in four stages: authenticate, send idempotently, receive verified events, then harden. Do not conflate them — most broken integrations pass stage one and skip stage three.
## Stage 1: client and credentials
Direct Sent v3 REST requests authenticate with the `x-api-key` header. An application proxy may accept `Authorization: Bearer` from its own callers, and the Sent MCP server uses client-managed OAuth, but neither changes the REST header sent to `api.sent.dm`. Organization keys may add `x-profile-id` to act for a child profile; a profile-scoped key that sends that header receives `403`.
| Language | Package | Client |
| --- | --- | --- |
| TypeScript | `@sentdm/sentdm` | `new SentDm()` |
| Python | `sentdm` (imports `sent_dm`) | `Sent()` or `AsyncSent()` |
| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient()` |
| Java | `dm.sent:sent-java` | `SentOkHttpClient.fromEnv()` |
| C# | `Sentdm` | `new SentClient()` |
| PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` |
| Ruby | `sentdm` | `Sentdm::Client.new` |
Every SDK except PHP reads `SENT_DM_API_KEY` automatically. Single-endpoint receiver samples read `SENT_DM_WEBHOOK_SECRET`; multi-tenant production receivers need a secret registry keyed by webhook id instead of one process-wide secret. Older documentation uses `SENT_API_KEY` and `SENT_WEBHOOK_SECRET` — treat those as aliases and standardize on the `SENT_DM_` names.
Choose the client lifecycle from the credential model. A single-account service with one server-managed key should reuse a long-lived client and its connection pool. A multi-tenant proxy that resolves a caller or profile credential per request should construct the client for that request and discard it, so tenant credentials cannot leak through shared state. Framework-specific wiring, the Ruby `messages.send_` naming quirk, and per-ecosystem background-work choices are in [references/sdk-and-frameworks.md](references/sdk-and-frameworks.md).
Validate configuration at boot and fail fast when the key is missing, rather than surfacing an auth error on the first customer send.
## Stage 2: idempotent sends
```json
{
"to": ["+14155551234"],
"template": {
"name": "order_confirmation",
"parameters": { "order_id": "12345" }
},
"sandbox": true
}
```
`to` is the only required field. Supply `template` or `text`, and omit `channel` to let automatic routing choose. Never write a `channel` array with several values expecting fallback — that broadcasts and multiplies charges. Channel decisions belong to `sent-routing-strategist`.
Send `Idempotency-Key` on every POST, PUT, and PATCH, derived deterministically from your own domain object (for example the order id plus the notification type) so a retry after a timeout cannot double-send. Keys are 1–255 characters of `[A-Za-z0-9_-]`, cached 24 hours per key per customer. A replay returns the cached body with `Idempotent-Replayed: true` and `X-Original-Request-Id`. A duplicate arriving while the original is still in flight waits up to five seconds and then fails `409 CONFLICT_001`; a `503 SERVICE_001` means the idempotency store was unavailable and the request was deliberately not executed.
`202` means accepted, not delivered. Persist the returned `message_id` values immediately with your own tenant, profile, and logical send identifiers. Webhook events carry the Sent message id and account data, but never your application's tenant identifier.
## Stage 3: verified webhook receiver
An integration without a receiver has no delivery truth. Register an endpoint, then verify every delivery: HMAC-SHA256 over `{x-webhook-id}.{x-webhook-timestamp}.{raw_body}`, keyed on the base64-decoded secret after stripping `whsec_`, compared in constant time, rejecting timestamps outside 300 seconds. No SDK ships a verifier in any language.
Acknowledge with `200` before doing work, and deduplicate on `{message_id}:{message_status}` for outbound events and `message_id` for inbound. Ten consecutive failed deliveries disable the endpoint. Full mechanics belong to `sent-webhook-engineer`; treat a verified, fast-acknowledging, deduplicating receiver as a launch requirement here.
## Stage 4: harden
### Retry policy by response class
| Response | Retry | How |
| --- | --- | --- |
| `2xx` | No | Success |
| `400`, `422` `VALIDATION_*` | No | Fix the request |
| `401`, `403` `AUTH_*` | No | Stop immediately; ten consecutive auth failures lock the credential with escalating lockouts |
| `404` `RESOURCE_*` | No | The referenced object does not exist |
| `409 CONFLICT_001` | Yes, once, after a pause | A concurrent duplicate is in flight |
| `429` | Yes | Honor `Retry-After`; jittered backoff |
| `5xx`, `503 SERVICE_001` | Yes | Exponential backoff with jitter and a ceiling |
| Timeout with no response | Retry safely only with evidence | Reuse the same `Idempotency-Key`; without one, there is no reliable API lookup by key or recipient, so do not automate a resend |
The standard limit is 200 requests per minute on a sliding window. `POST /v3/webhooks/{id}/rotate-secret` and `POST /v3/webhooks/{id}/test` are limited to 10 per minute. Rate-limit headers appear **only** on `429` responses, so pacing must be designed rather than measured — batch up to 1,000 recipients per request and pace at roughly one request per second for bulk work.
### Error handling
Errors arrive as `{success, data, error: {code, message, details, doc_url}, meta: {request_id, timestamp, version}}`. Branch on the `error.code` prefix family (`AUTH_`, `VALIDATION_`, `RESOURCE_`, `BUSINESS_`, `CONFLICT_`, `SERVICE_`, `INTERNAL_`) rather than on message text or on individual codes. The full 46-code catalog with retry classification is in [references/errors-and-limits.md](references/errors-and-limits.md).
Two codes are counterintuitive: `BUSINESS_003` and `BUSINESS_004` are documented as request-level errors, but on `POST /v3/messages` the request is accepted with `202` and the affected messages finalize as `BLOCKED` and `FILTERED`. Insufficient balance therefore does not fail the send call.
### Observability
Log `meta.request_id` on every response, success or failure — it is the correlation handle for support. Record the mapping from your logical send to the returned `message_id` values, and keep an append-only event history so a reroute's sequence remains auditable. Never log the API key, the webhook signing secret, `payment_details`, or raw recipient message content beyond your retention policy.
### Launch checklist
- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment.
- [ ] Client lifecycle matches credential scope: shared for one server-managed key, per request for tenant-supplied credentials.
- [ ] `Idempotency-Key` on every mutating call, derived deterministically.
- [ ] Retry policy distinguishes retryable from terminal by error family.
- [ ] Bulk paths pace against 200 requests per minute and batch to at most 1,000 recipients.
- [ ] Webhook receiver verifies signature and timestamp, returns `200` fast, and dedupes.
- [ ] Receiver returns non-2xx on genuine failure so Sent retries.
- [ ] `message_id` to tenant mapping is persisted before sending.
- [ ] `request_id` is logged; secrets and card data are not.
- [ ] Sandbox smoke test passes, then a real send reaches `DELIVERED`.
- [ ] Alerting covers webhook `consecutive_failures`, `429` volume, and filtered or blocked rates.
## Verification
Run the local preflight, which needs no credentials and no network:
```bash
python3 scripts/preflight.py --self-test
```
Then verify a real path with `"sandbox": true`, which authenticates and validates without executing, and finally with one live send confirmed to `DELIVERED` through the receiver.
## Boundaries
Use `sent-webhook-engineer` for receiver depth, `sent-routing-strategist` for channel choice, `sent-messaging` for a confirmed one-off send, `sent-two-way-messaging` for inbound and consent, `sent-profile-provisioning` for multi-tenant provisioning, and `migrate-to-sent` when replacing another CPaaS provider.