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/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 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 });

Both the SDK and the raw v3 client send the key to Sent as x-api-key. Sent also accepts it as Authorization: Bearer, but either way the header 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;

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

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 endpoint id), not from process
 * memory. A version 2 webhook sends no endpoint-id header (its webhook-id is
 * per message), so give each endpoint its own receiver URL to know which one
 * Sent is calling. A version 1 webhook also sends it as X-Webhook-ID.
 */
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 one endpoint. */
  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.

On this page