Project Setup

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, get three things from the Sent dashboard:

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 in the 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>. This is the single most important convention in the whole integration, and 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.

There is no SENT_DM_API_KEY read once into a boot-time global or singleton client anywhere in this blueprint — that's true whether you're multi-tenant (the key travels with the request) or single-tenant (you resolve it per request from config/secrets, not from a module-level variable set at import time). If you're reaching for a global client built from an env var at startup, stop and read Authentication first.

Install the SDK

Add the official Sent SDK for your language:

npm install @sentdm/sentdm
# or: pnpm add @sentdm/sentdm  ·  yarn add @sentdm/sentdm
pip install sentdm
# or: uv add sentdm

The 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>0.30.0</version> <!-- pin the current release; see the SDK page -->
</dependency>
dotnet add package Sentdm
composer require sentdm/sent-dm-php
bundle add sentdm
# or: gem install sentdm

See 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 above).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
  }
}

Either way, the client is built per request from the caller's key — never a boot-time singleton. Both the SDK and the raw v3 client send it 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 (see Authentication).

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

Notice what's absent: no SENT_DM_API_KEY. The schema validates operational settings and nothing more. The credential arrives on each request.

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
├── 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 touch process.env or 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.

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.

On this page