Reference Architecture

Before any code, get the shape right. A production Sent integration is small but has a few distinct responsibilities that are worth separating. Keeping them apart is what makes the integration testable, safe to scale, and easy to reason about when something fails at 3am.

The layers

A production Sent integration is organized the same way, regardless of language:

In plain English: config loads first, builds a per-request client, which the service layer uses to talk to Sent; routes call the service layer; a separate webhook receiver (with its own verifier) writes to the same state store. Read top to bottom.

LayerResponsibilityWhy it's separate
Config & validationParse and validate environment/config at startup; fail fast on misconfiguration.Nothing downstream should defend against missing config.
Per-request client factoryTurn the caller's Authorization: Bearer key into an SDK client for this request.The key is a request credential, not global state — see Authentication.
Sent service layerA thin wrapper over the SDK: one method per business operation, snake→camel mapping, error translation.Keeps SDK types out of your controllers and makes everything mockable in tests.
Routes / controllersHTTP surface, request validation, response shaping.Thin — all logic lives in the service layer.
Webhook receiverVerify → acknowledge → process inbound events.It's a public, state-mutating endpoint; it needs its own security discipline.
Signature verifierThe HMAC verification primitive, isolated and unit-tested.Security-critical and identical across every route that needs it.
State storeTrack message status from webhook events; hold webhook secrets; track idempotency/dedupe keys.The only stateful part — and the part you swap for Redis/DB in production.

This isn't heavyweight. In most languages each layer is one small file. The point is that "call the SDK" and "receive a webhook" never bleed into each other, and neither one reaches directly for global credentials.

Flow 1 — Outbound (you call Sent)

A client hits your API; you build a Sent client from the request's credential, call the service layer, and map the response back to your own contract.

In plain English: read each arrow top to bottom as one step in order. Your route builds a client, hands off to the service layer, which calls Sent and maps the response back — the 200 you return only means "accepted," not "delivered."

The message now exists on Sent's side in a non-terminal state (QUEUED). Its final outcome arrives later — asynchronously — over a webhook. That's Flow 2.

Flow 2 — Inbound (Sent calls you)

As the message progresses, Sent POSTs signed events to an endpoint you registered. Your receiver verifies, acknowledges immediately, then updates state.

In plain English: the alt/else box is an if/else — if the signature is bad or the timestamp is stale, reject with 401; otherwise acknowledge with 200 immediately, and only then update your own state store.

Two independent flows, one shared service layer. Everything else in this guide is filling in these boxes correctly.

The HTTP surface your integration exposes

This guide standardizes on one contract so the pattern applies unchanged regardless of your backend. You don't have to expose exactly this, but it's a well-tested starting point and the rest of the guide uses these paths.

MethodPathPurpose
GET/healthLiveness.
POST/api/auth/verifyValidate a key against GET /v3/me.
POST/api/messagesSend a templated message.
GET/api/messages/:idRead a tracked message's current status.
GET / POST/api/contactsList / create contacts.
DELETE/api/contacts/:idDelete a contact.
GET/api/templates (/:id)List / fetch templates.
POST/webhooks/sentReceive signed webhook events.
GET / POST/api/webhooksList / register webhook endpoints.
PATCH/api/webhooks/:id/toggleEnable / disable an endpoint.
POST/api/webhooks/:id/rotate-secretRotate an endpoint's signing secret.
DELETE/api/webhooks/:idRemove an endpoint.

Note the two distinct webhook paths. POST /webhooks/sent is where Sent delivers events to you (the receiver). The /api/webhooks* routes are where your client manages which endpoints exist (management, a proxy over the SDK). They are different concerns — see Endpoint management.

Stateful vs stateless

The only stateful pieces are the message-status store, the held webhook secrets, and the idempotency/dedupe keys that guard against duplicate sends and duplicate webhook processing. Starting with all three in-process memory is perfect for a single instance and for learning the pattern. In production you run more than one instance, so all three move to shared storage. We build the in-memory version first and show the swap in Scaling & deployment.

If you're on PHP-FPM, "in-process memory" doesn't mean what it does in Node, Python, or Go. Those runtimes keep one long-lived process handling many requests, so a plain in-memory map genuinely persists between them. PHP-FPM spins up a fresh process (or reuses one with no memory of prior requests) for every single request — an in-memory store built the same way as the other languages' examples will silently be empty on the very next request. For PHP, treat "in-process" as file-backed (or Redis/DB) from day one, not as an optional later upgrade.

Next steps

On this page