Authentication
This is the foundation everything else in the integration builds on, and it's the one thing most integrations get wrong. Get it right here and the rest of the guide falls into place.
The rule, stated once and precisely:
The Sent API key is a per-request credential. It arrives as
Authorization: Bearer <key>, and you use it to build an SDK client for that request. It is never a boot-time singleton, and it is never a stored or required environment variable.
Do not do this. The most common mistake in a Sent integration:
// ❌ Anti-pattern: a global client built from an env var at boot.
import SentDm from "@sentdm/sentdm";
export const sent = new SentDm({ apiKey: process.env.SENT_DM_API_KEY });This bakes a single tenant's key into your process, forces a secret into your environment,
makes multi-tenant serving impossible, and turns key rotation into a redeploy. There is no
SENT_DM_API_KEY env var anywhere in this blueprint. Build the client per request
instead.
Quick path. If you just need this working: pick your language/framework tab under The per-request client factory, copy the bearer-extraction function and the 401 guard, and call the factory at the top of every protected route. That's the whole pattern — the rest of this page is the why, plus rotation and multi-tenant detail you can come back to later.
Why per-request
Three concrete payoffs, not dogma:
- Multi-tenant by default. One deployment can serve many customers, each with their own key, because the credential travels with the request instead of living in the process.
- No secret at rest in your app. The key is never written to your environment, config, or disk. Your app is a pass-through; it holds the key only for the microseconds a request is in flight.
- Rotation is free. A customer rotates their key in the dashboard and simply sends the new one on the next request. Nothing to redeploy, no restart.
Who actually sends the Authorization: Bearer <key>?
This depends entirely on whether you have one Sent account or many:
- Multi-tenant / reseller. Each of your customers has their own Sent account and key. Here, "the caller" is genuinely external — your customer's own backend, or their session in your dashboard — and they legitimately hand you their Sent key on each call because it's theirs.
- Single-tenant — the common case. You have exactly one Sent account. There is no external
party who owns a Sent key, and you should never ship your Sent key to a browser or mobile
app. "Per-request" here means your own backend resolves the one key for each request or job,
rather than baking it into a global at boot. You still get the real payoffs — rotation
without a redeploy, no key frozen into a long-lived process — however you resolve it. No
dedicated secrets manager yet? Reading it from your normal config/env on each request (not
cached into a module-level variable at import/boot time) is a perfectly reasonable starting
point — the thing that matters is when it's read, not what system it's read from. Upgrade to
a real secrets manager when you actually need cross-service rotation, not before. The
Authorization: Bearer <key>shown throughout this page is the shape of the pattern; adapt the source of the key to whichever case you're in.
The per-request client factory
The pattern is identical in every language: pull the bearer token off the request, reject with
401 if it's missing, and construct a client bound to that key. Nothing is cached across
requests; nothing reads a global.
import SentDm from "@sentdm/sentdm";
import type { Request } from "express";
import { ApiError } from "../types";
/** Builds SDK-backed services bound to a specific customer's API key. */
export function servicesForApiKey(apiKey: string): Services {
const client = new SentDm({ apiKey });
return { sentService: new SentService(client), /* … */ };
}
/**
* Resolve the API key for THIS request. The ONLY valid source is
* `Authorization: Bearer <key>`. The key is never persisted.
*/
export function apiKeyFromRequest(req: Request): string | undefined {
const header = req.header("authorization");
const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
return bearer || undefined;
}
/** Per-request services — throws 401 when no bearer token is present. */
export function servicesForRequest(req: Request): Services {
const apiKey = apiKeyFromRequest(req);
if (!apiKey) {
throw new ApiError(401, "Unauthorized",
"Missing API key — send it as Authorization: Bearer <key>.");
}
return servicesForApiKey(apiKey);
}import SentDm from "@sentdm/sentdm";
import type { NextRequest } from "next/server";
export function apiKeyFromRequest(request: NextRequest | Request): string | undefined {
const header = request.headers.get("authorization");
const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
return bearer || undefined;
}
export class MissingApiKeyError extends Error {
readonly status = 401;
readonly code = "Unauthorized";
constructor() {
super("Missing API key — send it as Authorization: Bearer <key>.");
}
}
/** Builds an SDK client bound to a specific customer's key. */
export function clientForApiKey(apiKey: string): SentDm {
return new SentDm({ apiKey, maxRetries: 2, timeout: 30_000 });
}
/** Per-request client — throws MissingApiKeyError (401) when absent. */
export function clientForRequest(request: NextRequest | Request): SentDm {
const apiKey = apiKeyFromRequest(request);
if (!apiKey) throw new MissingApiKeyError();
return clientForApiKey(apiKey);
}from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sent_dm import AsyncSent
security = HTTPBearer(auto_error=False)
async def get_optional_token(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str | None:
return credentials.credentials if credentials else None
def _resolve_api_key(token: str | None) -> str:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key — send it as Authorization: Bearer <key>.",
)
return token
def get_sent_client(token: str | None = Depends(get_optional_token)) -> AsyncSent:
"""Per-request SDK client bound to the customer's API key."""
api_key = _resolve_api_key(token)
return AsyncSent(api_key=api_key)
def get_sent_service(client: AsyncSent = Depends(get_sent_client)) -> SentService:
return SentService(client)// ClientResolver builds a per-request SentClient from the caller's API key.
// The key lives only for the duration of the request. No env fallback.
type ClientResolver struct{ logger *zap.Logger }
var bearerRE = regexp.MustCompile(`(?i)^Bearer\s+(.+)$`)
var ErrMissingAPIKey = &models.HTTPError{
Code: http.StatusUnauthorized, ErrorCode: "UNAUTHORIZED",
Message: "Missing API key — send it as Authorization: Bearer <key>.",
}
func (r *ClientResolver) APIKeyFromRequest(c echo.Context) string {
header := c.Request().Header.Get("Authorization")
if m := bearerRE.FindStringSubmatch(strings.TrimSpace(header)); m != nil {
if key := strings.TrimSpace(m[1]); key != "" {
return key
}
}
return ""
}
// ForRequest → SentClient bound to this request's key, or 401 when absent.
func (r *ClientResolver) ForRequest(c echo.Context) (*SentClient, error) {
apiKey := r.APIKeyFromRequest(c)
if apiKey == "" {
return nil, ErrMissingAPIKey
}
return NewSentClient(apiKey, r.logger), nil
}
func NewSentClient(apiKey string, logger *zap.Logger) *SentClient {
return &SentClient{client: sentdm.NewClient(option.WithAPIKey(apiKey)), logger: logger}
}<?php
namespace App\Services;
use Illuminate\Http\Request;
use SentDm\Client;
/**
* Builds the Sent SDK client for THIS request. Call this directly from a
* controller or a request-scoped service — do NOT bind it in a
* ServiceProvider with `$this->app->singleton(...)`.
*/
class SentClientFactory
{
public function forRequest(Request $request): Client
{
$apiKey = $this->resolveApiKey($request);
if (empty($apiKey)) {
abort(401, 'Missing API key — send it as Authorization: Bearer <key>.');
}
return new Client(apiKey: $apiKey, requestOptions: ['maxRetries' => 2]);
}
private function resolveApiKey(Request $request): ?string
{
$header = (string) $request->header('Authorization', '');
if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
$bearer = trim($m[1]);
return $bearer !== '' ? $bearer : null;
}
return null;
}
}Why not the container? $this->app->singleton(Client::class, fn () => new Client(apiKey: config('services.sent.key'))) is the anti-pattern from the top of this page wearing Laravel
clothes. Under standard PHP-FPM it happens to be harmless because the whole process — and
the container with it — is thrown away after each request. But under Laravel Octane,
which reuses the same process across many requests exactly like Node or Python, that
singleton would freeze the first request's key into every request the worker ever serves
after it. Resolve SentClientFactory fresh (app()->bind(...) at most, never singleton)
and call forRequest($request) explicitly — don't let the container hold the client itself.
/**
* Builds the Sent SDK client for THIS request. The API key is a per-customer
* RUNTIME value, never required at boot and never persisted. No env fallback.
*/
class SentClientFactory
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly int $maxRetries = 2,
) {}
public function create(): Client
{
$apiKey = $this->resolveApiKey();
if (empty($apiKey)) {
throw new HttpException(401,
'Missing API key — send it as Authorization: Bearer <key>.');
}
return new Client(apiKey: $apiKey, requestOptions: ['maxRetries' => $this->maxRetries]);
}
private function resolveApiKey(): ?string
{
$request = $this->requestStack->getCurrentRequest();
$header = (string) ($request?->headers->get('Authorization') ?? '');
if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
$bearer = trim($m[1]);
return $bearer !== '' ? $bearer : null;
}
return null;
}
}class ApplicationController < ActionController::API
private
# Resolve the customer API key for THIS request. nil when no bearer token.
def current_api_key
header = request.headers["Authorization"]
header&.match(/\ABearer\s+(.+)\z/i)&.captures&.first&.strip.presence
end
# Guard for protected /api/* routes. 401 when no bearer token is present.
def require_api_key!
return if current_api_key.present?
render json: {
error: { code: "UNAUTHORIZED",
message: "Missing API key — send it as Authorization: Bearer <key>." }
}, status: :unauthorized
end
# Per-request SDK client built from the resolved key.
def sent_client
Sentdm::Client.new(api_key: current_api_key)
end
endNote the framework-idiomatic shapes: Express uses a plain factory function, FastAPI a
Depends chain, Go a ClientResolver, Laravel a plain service class called explicitly (never
container-bound, see the warning above), Symfony a DI-injected factory scoped to
RequestStack, Rails a controller before_action guard. They all implement the same
contract — build fresh from the request, never cache across one.
One regex, everywhere. Every backend matches ^Bearer\s+(.+)$ case-insensitively,
trims, and treats empty as absent. Keep that parsing in the factory so no controller
re-implements it.
Single-tenant vs multi-tenant
The per-request factory makes both trivial — and it's why multi-tenant is safe.
- Single-tenant. Your app has one Sent account. Your own backend resolves the key from a secrets manager per request instead of an external caller supplying it (see above); the factory builds a client from it the same way. Nothing is cached server-side across requests.
- Multi-tenant. Your app serves many customers, each with their own Sent account and key. Because the client is constructed from the request's key and discarded when the request ends, there is no shared client and no risk of one tenant's calls going out under another tenant's key. Tenant isolation is structural, not something you have to remember to enforce.
A boot-time singleton silently makes multi-tenant impossible: every request would go out under whatever single key the process started with. The per-request factory is what keeps tenant A's messages from being sent on tenant B's account.
No key → 401
A protected route with no bearer token must fail closed with 401 — before any SDK call. Every
factory above does exactly this. Two things to keep straight:
- Unprotected routes never gate on it.
GET /healthand the webhook receiverPOST /webhooks/sentdon't build an SDK client, so they never call the factory and never return401. (The receiver has its own auth: the signature.) - Fail before the network. Reject the missing key locally; don't send an unauthenticated
request to Sent just to bounce a
401back.
Verifying a key
Before trusting a key — or to let a user confirm the one they pasted works — validate it
against GET /v3/me. This is the canonical POST /api/auth/verify endpoint: it makes one
authenticated call and reports whether the key is good, without leaking upstream error shapes.
router.post("/verify", asyncHandler(async (req, res) => {
const { apiKey } = req.body as { apiKey?: string };
if (!apiKey) {
return res.status(400).json({ valid: false, error: { status: 400, code: "MissingApiKey", message: "apiKey is required" } });
}
const baseUrl = process.env["SENT_BASE_URL"] ?? "https://api.sent.dm";
const response = await fetch(`${baseUrl}/v3/me`, {
headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
});
const data = await response.json();
if (response.ok) return res.status(200).json({ valid: true, account: data });
return res.status(200).json({
valid: false,
error: { status: response.status, code: data.code ?? "ApiError",
message: data.message ?? "Authentication failed" },
});
}));@router.post("/api/auth/verify")
async def verify(body: dict):
api_key = body.get("apiKey")
if not api_key:
return {"valid": False, "error": {"status": 400, "code": "MissingApiKey", "message": "apiKey is required"}}
base_url = os.getenv("SENT_BASE_URL", "https://api.sent.dm")
async with httpx.AsyncClient() as http:
resp = await http.get(f"{base_url}/v3/me",
headers={"x-api-key": api_key})
data = resp.json()
if resp.is_success:
return {"valid": True, "account": data}
return {"valid": False, "error": {"status": resp.status_code,
"code": data.get("code", "ApiError"),
"message": data.get("message", "Authentication failed")}}verify returns 200 with { valid: false, error } for a bad key rather than propagating
the upstream 401. That's a deliberate UX choice for a "test your key" screen — the request
to your endpoint succeeded; the key is what's invalid.
Secret hygiene and rotation
The key is a bearer credential: anyone holding it can send on the account. Treat it accordingly.
- Never log it. Not in request logs, not in error payloads, not in traces. Redact the
Authorizationheader at your logging boundary. - Never persist it. No env var, no config file, no database column, no disk. It exists only in the request scope. When the request ends, so does the key. The one narrow, deliberate exception is queued/async work, where the key necessarily outlives the original request — see Errors & resilience for the hygiene that exception requires.
- Transport only over TLS. The bearer token is only as safe as the channel — require HTTPS end to end.
- Scope who can send it. In your own front end, the key comes from the authenticated user's session or vault, attached per request — never hard-coded or shipped to the browser.
- Rotation is a non-event. Because nothing caches the key, a customer rotates in the dashboard and sends the new key on the next request. No redeploy, no restart, no coordinated cutover. This is the direct reward for not baking the key into a singleton.
If rotating a key would require you to redeploy or restart, that's the singleton anti-pattern leaking back in. In this architecture, rotation should require zero changes to your running app.
Next steps
You can now build an authenticated client for any request. Put it to work on the outbound path.
Project Setup
Prerequisites, installing the SDK in your language, the SDK-vs-raw-REST decision, config validation that fails fast, and the layered folder layout that realizes the reference architecture.
Sending Messages
The outbound path done right — a thin, testable service layer over messages.send, multi-recipient response mapping, channels, sandbox sends, and where delivery status actually comes from.