Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "Official Sent skills and remote MCP integration for messaging engineering, operations, migration, and specialist channel workflows.",
"version": "0.2.0"
"version": "0.2.1"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
python -m pip install "skills-ref @ git+https://github.com/agentskills/agentskills.git@217be548739f21d6008915c29aefe320ea1a90af#subdirectory=skills-ref"

- name: Install Claude Code validator
run: npm install --global @anthropic-ai/claude-code@2.1.226
run: npm install --global @anthropic-ai/claude-code@2.1.287

- name: Reject generated adapter drift
run: python scripts/generate_adapters.py --check
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported
Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog.

The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint.

The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth.
2 changes: 2 additions & 0 deletions adapter-sources/shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported
Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog.

The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint.

The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth.
6 changes: 5 additions & 1 deletion claude-plugins/sent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sent",
"version": "0.2.0",
"version": "0.2.1",
"description": "Official Sent plugin for SMS, WhatsApp, RCS, scheduled template sends, Sender Profiles, SMS compliance, authorized feedback, contacts, analytics, API integration, webhooks, routing, and migration.",
"author": {
"name": "Sent",
Expand Down Expand Up @@ -36,6 +36,10 @@
"feedback",
"scheduled-messaging"
],
"documentationUrl": "https://github.com/sentdm/sent-plugin#readme",
"supportUrl": "https://www.sent.dm/en/company/contact-support",
"privacyPolicyUrl": "https://www.sent.dm/en/legal/privacy-policy",
"termsOfServiceUrl": "https://www.sent.dm/en/legal/terms-of-service",
"commands": "./.claude/commands",
"skills": "./skills",
"mcpServers": "./.mcp.json"
Expand Down
2 changes: 2 additions & 0 deletions claude-plugins/sent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,5 @@ Dashboard volume counts delivered/read outbound SMS and WhatsApp over supported
Use the connected server's tool schemas for argument names and availability. The [public MCP landing page](https://mcp.sent.dm) lists the current surface; some documentation pages may describe an earlier catalog.

The bundled migration inventory scanner reads local regular files in the selected repository and makes no network requests. It skips symbolic links and environment files and omits source excerpts from reports. Other bundled utilities validate supplied local payloads or analyze supplied local exports. Remote account reads and authorized writes use the declared Sent MCP endpoint.

The webhook signature utility runs locally with an explicitly supplied signing secret on standard input (`--secret-stdin`); it makes no network requests and does not read installer credentials or environment variables. Application integration examples require explicitly supplied API keys and signing secrets. Plugin authentication remains client-managed OAuth.
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class Rule:
),
Rule(
"twilio-sdk", "twilio", r"\b(?:from\s+twilio|require\(['\"]twilio|com\.twilio|Twilio\.Rest|twilio-go)\b",
"rewrite", "Replace the Twilio client with a Sent SDK client reading SENT_DM_API_KEY.",
"rewrite", "Replace the Twilio client with a Sent SDK client with an explicitly supplied application API key.",
),
Rule(
"twilio-messages-endpoint", "twilio", r"api\.twilio\.com/2010-04-01/Accounts/[^/]*/Messages",
Expand Down
16 changes: 8 additions & 8 deletions claude-plugins/sent/skills/sent-integration-starter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ Direct Sent v3 REST requests authenticate with the `x-api-key` header. An applic

| 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()` |
| TypeScript | `@sentdm/sentdm` | `new SentDm({ apiKey })` |
| Python | `sentdm` (imports `sent_dm`) | `Sent(api_key=api_key)` or `AsyncSent(api_key=api_key)` |
| Go | `github.com/sentdm/sent-dm-go` | `sentdm.NewClient(option.WithAPIKey(apiKey))` |
| Java | `dm.sent:sent-java` | `SentOkHttpClient.builder().apiKey(apiKey).build()` |
| C# | `Sentdm` | `new SentClient { ApiKey = apiKey }` |
| PHP | `sentdm/sent-dm-php` | `new SentDm\Client($apiKey)` |
| Ruby | `sentdm` | `Sentdm::Client.new` |
| Ruby | `sentdm` | `Sentdm::Client.new(api_key: api_key)` |

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.
For generated application code, require an explicit, non-empty API key supplied by the application owner and pass it through the SDK's credential option. Inject the signing secret resolved for each webhook id into receiver helpers. Never inspect the plugin installer's environment, credential store, or MCP tokens; the plugin itself authenticates through client-managed OAuth. Multi-tenant production receivers need a secret registry keyed by webhook id.

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).

Expand Down Expand Up @@ -81,7 +81,7 @@ Log `meta.request_id` on every response, success or failure — it is the correl

### Launch checklist

- [ ] Credentials load from the environment; nothing is committed, and separate keys exist per environment.
- [ ] Credentials are explicitly supplied from application-owned configuration; 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- [Package matrix](#package-matrix)
- [Client construction per language](#client-construction-per-language)
- [Configuration and environment variables](#configuration-and-environment-variables)
- [Explicit application configuration](#explicit-application-configuration)
- [Framework wiring](#framework-wiring)
- [Background processing per ecosystem](#background-processing-per-ecosystem)
- [Multi-tenant credential patterns](#multi-tenant-credential-patterns)
Expand All @@ -29,11 +29,13 @@ No SDK ships a webhook signature verifier in any language. That code is always a

## Client construction per language

These are application examples, not plugin authentication code. Each `apiKey` or `api_key` below is a non-empty value explicitly supplied by the application owner for the intended account. Validate it before construction so an absent value cannot trigger an SDK environment fallback. Never read the plugin installer's environment, credential store, or MCP tokens. Plugin operations use client-managed OAuth instead.

```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 });
// apiKey is explicitly supplied for the authorized application account.
export const sent = new SentDm({ apiKey, maxRetries: 3, timeout: 30_000 });

const response = await sent.messages.send({
to: ['+14155551234'],
Expand All @@ -44,8 +46,8 @@ const response = await sent.messages.send({
```python
from sent_dm import Sent, AsyncSent

client = Sent(max_retries=2, timeout=60.0) # reads SENT_DM_API_KEY
async_client = AsyncSent()
client = Sent(api_key=api_key, max_retries=2, timeout=60.0)
async_client = AsyncSent(api_key=api_key)

response = client.messages.send(
to=["+14155551234"],
Expand All @@ -54,46 +56,40 @@ response = client.messages.send(
```

```go
client := sentdm.NewClient() // or option.WithAPIKey(...)
client := sentdm.NewClient(option.WithAPIKey(apiKey))
response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{
To: []string{"+14155551234"},
})
```

```java
SentClient client = SentOkHttpClient.fromEnv(); // SENT_DM_API_KEY or sent.dmApiKey
SentClient client = SentOkHttpClient.builder().apiKey(apiKey).build();
MessageSendResponse response = client.messages().send(params);
```

```csharp
using Sentdm;
SentClient client = new(); // reads SENT_DM_API_KEY
SentClient client = new() { ApiKey = apiKey };
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
$client = new Client(apiKey: $apiKey);
$result = $client->messages->send(to: ['+14155551234'], template: ['name' => 'order_confirmation']);
```

```ruby
require "sentdm"
client = Sentdm::Client.new # reads SENT_DM_API_KEY
client = Sentdm::Client.new(api_key: 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 |
## Explicit application configuration

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.
Pass the authorized application's API key as the SDK's explicit credential option. Pass a webhook signing secret to verification helpers as a separate argument, resolved for the received webhook id. Keep both values in application-owned secret storage and do not print them, put them in prompts, or reuse installer credentials. This plugin does not declare user-config API keys because its MCP connection uses client-managed OAuth.

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.

Expand Down
2 changes: 2 additions & 0 deletions claude-plugins/sent/skills/sent-webhook-engineer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Verification procedure, in order:

The scheme is Svix-compatible. No Sent SDK ships a verification helper in any language, so this code is always hand-written — use [scripts/verify_signature.py](scripts/verify_signature.py) as the reference implementation and oracle.

The local utility accepts a signing secret explicitly on standard input with `--secret-stdin` and makes no network requests. It never reads environment variables or installer credentials. Supply only the secret authorized for the delivery being checked; do not put it in command arguments or chat prompts.

**`x-webhook-id` is not an event id.** It identifies the endpoint and repeats forever. Using it as a dedupe key silently collapses every event into one. Read [references/webhook-signature-and-dedupe.md](references/webhook-signature-and-dedupe.md) for the dedupe keys to derive per event type.

## Failure triage order
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Every recipe follows the same four steps: read the raw body, verify the signatur
| Sinatra | `request.body.read` then `request.body.rewind` | Same |
| ASP.NET Core | `new StreamReader(request.Body).ReadToEndAsync()` | Read before model binding touches the stream |

Environment variables: the SDKs read `SENT_DM_API_KEY` by default, and the receiver samples use `SENT_DM_WEBHOOK_SECRET`. Older documentation pages use `SENT_API_KEY` and `SENT_WEBHOOK_SECRET`; treat those as aliases and standardize on the `SENT_DM_` names in new code.
These are receiver application examples, not plugin authentication code. Supply the signing secret explicitly from application-owned configuration, resolved for the received webhook id. The `signingSecret` and `signing_secret` variables below represent that authorized, injected value. Reject missing values; never inspect the plugin installer's environment, credential store, or MCP tokens. The plugin's MCP connection uses client-managed OAuth.

## Node and TypeScript

Expand All @@ -42,8 +42,7 @@ import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verify(rawBody: string, webhookId: string, timestamp: string, header: string): boolean {
const secret = process.env.SENT_DM_WEBHOOK_SECRET ?? "";
export function verify(rawBody: string, webhookId: string, timestamp: string, header: string, secret: string): boolean {
if (!secret || !header?.startsWith("v1,")) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false;

Expand All @@ -67,6 +66,7 @@ export async function POST(request: Request): Promise<Response> {
request.headers.get("x-webhook-id") ?? "",
request.headers.get("x-webhook-timestamp") ?? "",
request.headers.get("x-webhook-signature") ?? "",
signingSecret,
);
if (!ok) return new Response("invalid signature", { status: 401 });

Expand All @@ -80,7 +80,7 @@ Express, scoping the raw parser to the webhook path only:
```js
app.post("/webhooks/sent", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"))) {
if (!verify(rawBody, req.get("x-webhook-id"), req.get("x-webhook-timestamp"), req.get("x-webhook-signature"), signingSecret)) {
return res.status(401).send("invalid signature");
}
res.status(200).end();
Expand All @@ -93,13 +93,12 @@ Mount `express.json()` on other routers rather than globally with `app.use`. In
## Python

```python
import base64, hashlib, hmac, os, time
import base64, hashlib, hmac, time

TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str) -> bool:
secret = os.environ.get("SENT_DM_WEBHOOK_SECRET", "")
def verify(raw_body: bytes, webhook_id: str, timestamp: str, header: str, secret: str) -> bool:
if not secret or not header.startswith("v1,"):
return False
try:
Expand All @@ -121,7 +120,7 @@ async def receive(request: Request, background: BackgroundTasks):
raw = await request.body()
if not verify(raw, request.headers.get("x-webhook-id", ""),
request.headers.get("x-webhook-timestamp", ""),
request.headers.get("x-webhook-signature", "")):
request.headers.get("x-webhook-signature", ""), signing_secret):
raise HTTPException(status_code=401, detail="invalid signature")
background.add_task(process_event, json.loads(raw))
return {"received": True}
Expand All @@ -132,8 +131,7 @@ Django reads `request.body` in the view and must exempt the route from CSRF. Fla
## Go

```go
func Verify(rawBody []byte, webhookID, timestamp, header string) bool {
secret := os.Getenv("SENT_DM_WEBHOOK_SECRET")
func Verify(rawBody []byte, webhookID, timestamp, header, secret string) bool {
if secret == "" || !strings.HasPrefix(header, "v1,") {
return false
}
Expand Down Expand Up @@ -165,7 +163,7 @@ public ResponseEntity<Void> receive(
@RequestHeader("x-webhook-timestamp") String timestamp,
@RequestHeader("x-webhook-signature") String signature) throws Exception {

if (!WebhookSignature.verify(payload, webhookId, timestamp, signature)) {
if (!WebhookSignature.verify(payload, webhookId, timestamp, signature, signingSecret)) {
return ResponseEntity.status(401).build();
}
events.submit(payload); // @Async executor
Expand All @@ -182,7 +180,8 @@ Laravel middleware runs before the controller and reads `$request->getContent()`
```php
public function handle(Request $request, Closure $next)
{
$secret = env('SENT_DM_WEBHOOK_SECRET', '');
$secret = $this->signingSecret; // Injected for this webhook endpoint.
if (!$secret) abort(401);
$signed = $request->header('x-webhook-id') . '.' . $request->header('x-webhook-timestamp') . '.' . $request->getContent();
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $key, true));
Expand All @@ -200,10 +199,9 @@ Dispatch a `ShouldQueue` job from the controller. Symfony follows the same patte
## Ruby

```ruby
def verified?(request)
def verified?(request, secret)
raw = request.body.read
request.body.rewind
secret = ENV.fetch("SENT_DM_WEBHOOK_SECRET", "")
timestamp = request.get_header("HTTP_X_WEBHOOK_TIMESTAMP").to_s
return false if secret.empty? || (Time.now.to_i - timestamp.to_i).abs > 300

Expand All @@ -229,7 +227,7 @@ app.MapPost("/webhooks/sent", async (HttpRequest request) =>
request.Headers["x-webhook-id"],
request.Headers["x-webhook-timestamp"],
request.Headers["x-webhook-signature"],
Environment.GetEnvironmentVariable("SENT_DM_WEBHOOK_SECRET")))
signingSecret))
{
return Results.Unauthorized();
}
Expand Down
Loading