Setting Up the Project: SDK, Config, and Folder Layout
You've seen the shape of the integration. Now stand up a project that realizes it: install the SDK, wire up config that fails fast on startup, and lay out the layers so "call the SDK" and "receive a webhook" never bleed into each other.
Everything here is framework-agnostic. Pick your language in the tabs; the pattern is the same.
Prerequisites
Before any code, you need three things:
A Sent account. Sign up and complete onboarding. If you're brand new, run the Quickstart end-to-end first. This guide assumes you've sent at least one message.
An API key. Generate one on the API Keys page in your Sent
dashboard. Keep it handy, but note that it does not
go into an env var or a config file. It's a per-request credential, supplied as
Authorization: Bearer <key>, never read once into a boot-time global or singleton client.
Every chapter that follows builds on this convention; it's covered in
full in Authentication.
At least one approved template. Sent is template-first: outbound messages reference a
template by id or name. Create and get one approved so you have something to send. See
Templates.
Install the SDK
Add the official Sent SDK for your language:
npm install @sentdm/sentdm
# or: pnpm add @sentdm/sentdm · yarn add @sentdm/sentdmpip install sentdm
# or: uv add sentdmThe SDK exposes both a sync Sent and an async AsyncSent client.
go get github.com/sentdm/sent-dm-go<!-- Maven -->
<dependency>
<groupId>dm.sent</groupId>
<artifactId>sent-java</artifactId>
<version><!-- pin the latest release; see the SDK reference --></version>
</dependency>dotnet add package Sentdmcomposer require sentdm/sent-dm-phpbundle add sentdm
# or: gem install sentdmSee the SDK reference for the full method surface in each language.
SDK or raw v3 REST?
Almost every integration should use the SDK. It handles auth headers, retries, the response envelope, and gives you typed methods. Reach for the raw v3 REST API only when you have a specific reason:
| Use the SDK when… | Use raw v3 REST when… |
|---|---|
| You want typed methods, built-in retries, and less boilerplate. | You're on a runtime with no SDK, or want zero dependencies. |
| You're integrating a supported language (all seven in the preceding section). | You need to see exactly what goes over the wire (learning, debugging). |
| You want the maintained happy path. | You need a request shape the SDK doesn't expose yet. |
The raw path looks like a small typed fetch client that sends the key as an x-api-key
header and unwraps the v3 envelope { success, data, error, meta }.
import SentDm from "@sentdm/sentdm";
// One typed client, per request, bound to the caller's key.
const client = new SentDm({ apiKey });
const res = await client.messages.send({ to, template });// A hand-rolled client: you own the transport and the envelope.
export class SentV3Client {
constructor(private opts: { apiKey: string; baseUrl: string }) {}
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${this.opts.baseUrl}${path}`, {
method,
headers: {
"x-api-key": this.opts.apiKey,
"content-type": "application/json",
accept: "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const parsed = JSON.parse(await res.text());
if (!res.ok || !parsed?.success) {
throw new SentV3ApiError(parsed?.error?.message ?? res.statusText, res.status);
}
return parsed.data as T; // unwrap the { success, data, error, meta } envelope
}
}Both the SDK and the raw v3 client send the key to Sent as x-api-key; that header
name is internal to how you talk to Sent, separate from whatever scheme your own API uses
to receive the key from its callers.
Validate config at startup: fail fast
Parse and validate your configuration at boot, and exit immediately if it's wrong. Nothing downstream should have to defend against missing config.
But be precise about what's in config. Config holds operational knobs: base URL, log
level, rate-limit settings, port. It does not hold the API key (that's per-request) and it
does not require a webhook signing secret (that's a runtime value born when a customer
registers a webhook; see Endpoint management). A
SENT_DM_WEBHOOK_SECRET may appear only as an optional local/curl convenience, never as a
required boot variable.
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.string().transform(Number).default("3001"),
// Operational knobs only. NOT the API key (per-request), and the webhook
// secret is a runtime value — optional here, not required at boot.
SENT_DM_WEBHOOK_SECRET: z.string().min(1).optional(),
LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error", "fatal"]).default("info"),
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default("900000"),
RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default("100"),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment variables:", parsed.error.format());
process.exit(1); // fail fast
}
export const env = parsed.data;from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
debug: bool = Field(default=False, alias="DEBUG")
# Optional runtime convenience only — not required at boot.
sent_dm_webhook_secret: str | None = Field(default=None, alias="SENT_DM_WEBHOOK_SECRET")
sent_base_url: str | None = Field(default=None, alias="SENT_BASE_URL")
rate_limit: str = Field(default="100/minute", alias="RATE_LIMIT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
@lru_cache
def get_settings() -> Settings:
return Settings() # pydantic raises on invalid config at first access// Loaded with envconfig (kelseyhightower/envconfig); viper works the same way.
type Config struct {
Server ServerConfig
Sent SentConfig
RateLimit RateLimitConfig
}
type ServerConfig struct {
Port string `envconfig:"PORT" default:"3031"`
Environment string `envconfig:"ENVIRONMENT" default:"development"`
}
// Per-customer RUNTIME value, optional env fallback for dev/curl. Never
// required to boot the server.
type SentConfig struct {
WebhookSecret string `envconfig:"SENT_DM_WEBHOOK_SECRET"`
}
type RateLimitConfig struct {
RequestsPerSecond float64 `envconfig:"RATE_LIMIT_RPS" default:"10"`
BurstSize int `envconfig:"RATE_LIMIT_BURST" default:"20"`
}
func Load() (*Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err) // fail fast
}
return &cfg, nil
}// config/sent.php — operational knobs only, sourced from env at boot.
return [
'base_url' => env('SENT_BASE_URL', 'https://api.sent.dm'),
// Optional runtime convenience only — never required to boot.
'webhook_secret' => env('SENT_DM_WEBHOOK_SECRET'),
'rate_limit' => env('RATE_LIMIT', '100,1'), // "max,per-minute"
];<?php
// app/Providers/SentConfigServiceProvider.php — fail fast on boot.
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use RuntimeException;
class SentConfigServiceProvider extends ServiceProvider
{
public function boot(): void
{
$baseUrl = config('sent.base_url');
if (empty($baseUrl) || ! filter_var($baseUrl, FILTER_VALIDATE_URL)) {
throw new RuntimeException('Invalid or missing SENT_BASE_URL.');
}
// No SENT_DM_API_KEY check here — it's a per-request value, not config.
}
}The folder layout
Realize the reference architecture's layers as small, single-responsibility files. Exact filenames vary by framework, but the seams are the same everywhere:
src/
├── config/ # Config & validation — parse + validate env at boot, fail fast
│ └── env.(ts|py|go)
├── container/ (or clients/, dependencies)
│ └── client.* # Per-request client factory — Bearer key → SDK client
├── services/ # Sent service layer — thin wrappers, snake→camel mapping, errors
│ └── sent.service.*
├── controllers/ (or routes/)
│ ├── auth.* # POST /api/auth/verify
│ ├── messages.* # POST /api/messages, GET /api/messages/:id
│ ├── contacts.* # GET/POST /api/contacts, DELETE /api/contacts/:id
│ ├── templates.* # GET /api/templates(/:id)
│ └── webhooks.* # management: GET/POST /api/webhooks, toggle, rotate, delete
├── middleware/
│ ├── async-handler.* # asyncHandler — route async errors into the error handler
│ └── validate.* # validateBody — schema-check req.body, 400 on failure
├── webhooks/
│ ├── receiver.* # POST /webhooks/sent — verify → ack → process
│ ├── verifier.* # HMAC signature primitive (unit-tested in isolation)
│ └── secret-store.* # in-memory (prod: Redis/DB)
└── store/
└── messages.* # message-status store (in-memory; prod: Redis/DB)The two rules that keep this honest:
- The client factory is the only place that reads the request's API key and constructs
a client. Controllers ask it for a client; they never read
process.envor a global. - The verifier and receiver are isolated from the outbound service layer. A public, state-mutating endpoint deserves its own security discipline.
This is deliberately lightweight. In most languages each layer is one small file.
Shared helpers
The TypeScript samples across this guide reuse three small pieces of app code. They are not SDK exports; define them once in The folder layout and import them everywhere. Later pages link back here at each symbol's first use.
asyncHandler forwards a rejected promise from an async route into Express's error
middleware, so one edge handler renders every error (see
Errors & resilience):
// middleware/async-handler.ts
import type { NextFunction, Request, RequestHandler, Response } from "express";
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}validateBody checks req.body against a Zod schema and rejects invalid input with a 400
before the handler runs. It rethrows as the integration's single ApiError type, defined in
Errors & resilience:
// middleware/validate.ts
import type { RequestHandler } from "express";
import { ZodError, type ZodSchema } from "zod";
import { ApiError } from "../types";
export function validateBody(schema: ZodSchema): RequestHandler {
return (req, _res, next) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof ZodError) {
const details = error.errors.map((e) => ({ path: e.path.join("."), message: e.message }));
return next(new ApiError(400, "ValidationError", "Request validation failed", { errors: details }));
}
next(error);
}
};
}webhookSecretStore holds webhook signing secrets in memory as a set of candidates per
endpoint id, so the receiver can verify whichever endpoint Sent delivers to and rotation never
needs a coordinated swap (see Endpoint management):
// webhooks/secret-store.ts
/*
* Production note: a real multi-instance service would load the secret per
* delivery from its own datastore (keyed by X-Webhook-ID / endpoint), not from
* process memory.
*/
const secretsByEndpoint = new Map<string, Set<string>>();
export const webhookSecretStore = {
/** Hold a signing secret for an endpoint — on create and on rotate. */
remember(endpointId: string, secret: string): void {
const candidates = secretsByEndpoint.get(endpointId) ?? new Set<string>();
candidates.add(secret);
secretsByEndpoint.set(endpointId, candidates);
},
/** Candidate secrets for the endpoint named by X-Webhook-ID. */
candidatesFor(endpointId: string): string[] {
return [...(secretsByEndpoint.get(endpointId) ?? [])];
},
/** Retire an old secret once deliveries verify against the new one. */
retire(endpointId: string, secret: string): void {
secretsByEndpoint.get(endpointId)?.delete(secret);
},
};The Python and Go samples don't need the first two: FastAPI's Pydantic models and dependency
injection, and Echo's Bind plus centralized error handling, cover the same ground natively.
Their secret stores (webhook_secret_store in Python, h.secrets in Go) mirror
remember/candidatesFor.
Next steps
The client factory is the heart of this layout, and it hinges on one idea that trips up most integrations. Get it right next.
Reference Architecture
The anatomy of a production Sent integration: the layers, the outbound and inbound request flows, the HTTP surface your app exposes, and where state lives.
Authenticating Requests with Per-Request Clients
Build a Sent SDK client from the request's bearer key. Per-request client factories in seven frameworks, 401 guards, key verification, and easy rotation.