Contacts & Templates
Sending is the interesting call; contacts and templates are the supporting cast. Both follow the
exact same shape as sending messages: a thin service method wraps the
SDK resource, maps the snake_case response to your camelCase contract, and lets the controller
stay dumb. Once you've written send, these write themselves.
Concepts first: contacts and templates. Guides: managing contacts and working with templates. This page is about wiring them into your service layer.
Contacts
Three operations: contacts.list, contacts.create, contacts.delete. The mapping is small
but non-negotiable — phone_number → phoneNumber, created_at → createdAt — and it happens in
the service, never in the controller.
// services/sent.service.ts
async listContacts(): Promise<ContactItem[]> {
const response = await this.client.contacts.list({ page: 1, page_size: 100 });
const contacts = response.data?.contacts ?? [];
return contacts.map((c) => ({
id: c.id,
phoneNumber: c.phone_number, // ← snake → camel
createdAt: c.created_at,
}));
}
async createContact(phoneNumber: string, sandbox?: boolean): Promise<ContactItem> {
const response = await this.client.contacts.create({
phone_number: phoneNumber,
sandbox: sandbox ?? false,
});
const c = response.data ?? {};
return { id: c.id, phoneNumber: c.phone_number, createdAt: c.created_at };
}
async deleteContact(id: string): Promise<void> {
await this.client.contacts.delete(id, {});
}# app/services/sent_service.py
async def list_contacts(self) -> CanonicalContactList:
response = await self._client.contacts.list(page=1, page_size=100)
contacts = response.data.contacts if response.data and response.data.contacts else []
return CanonicalContactList(
items=[
CanonicalContact(id=c.id, phoneNumber=c.phone_number, createdAt=c.created_at)
for c in contacts
]
)
async def create_contact(self, request: CanonicalCreateContactRequest) -> CanonicalContact:
response = await self._client.contacts.create(
phone_number=request.phoneNumber,
sandbox=request.sandbox,
)
c = response.data
return CanonicalContact(
id=getattr(c, "id", None),
phoneNumber=getattr(c, "phone_number", None),
createdAt=getattr(c, "created_at", None),
)
async def delete_contact(self, contact_id: str) -> None:
await self._client.contacts.delete(contact_id)// internal/services/sentclient.go
func (c *SentClient) CreateContact(ctx context.Context, phoneNumber string, sandbox bool) (*sentdm.APIResponseOfContact, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
params := sentdm.ContactNewParams{PhoneNumber: phoneNumber}
if sandbox {
params.Sandbox = sentdm.Bool(true)
}
return c.client.Contacts.New(ctx, params)
}
// internal/models/contract.go — map SDK contact → contract shape.
func NewContactResponse(c sentdm.ContactResponse) ContactResponse {
return ContactResponse{
ID: c.ID,
PhoneNumber: c.PhoneNumber, // ← snake → camel at the JSON tag
CreatedAt: c.CreatedAt,
}
}The controller just delegates and picks status codes — 201 on create, 204 on delete:
// controllers/contacts.controller.ts
router.get('/', asyncHandler(async (req, res) => {
const { sentService } = servicesForRequest(req);
res.status(200).json({ items: await sentService.listContacts() });
}));
router.post('/', validateBody(CreateContactSchema), asyncHandler(async (req, res) => {
const { sentService } = servicesForRequest(req);
const contact = await sentService.createContact(req.body.phoneNumber, req.body.sandbox);
res.status(201).json(contact);
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { sentService } = servicesForRequest(req);
await sentService.deleteContact(String(req.params.id));
res.status(204).send();
}));Templates
Templates are read-only from your integration's side — you list them and retrieve them; you
don't create them here (that's a console/approval workflow). Two operations: templates.list
and templates.retrieve.
Two mapping gotchas to handle explicitly:
- The SDK exposes
channels(an array); your contract wants a singlechannel— takechannels[0]. - The SDK
Templatemodel carries no renderedbody— emitnullfor it. Don't invent it.
// services/sent.service.ts
async listTemplates(): Promise<TemplateItem[]> {
const response = await this.client.templates.list({ page: 1, page_size: 100 });
const templates = response.data?.templates ?? [];
return templates.map((t) => ({
id: t.id,
name: t.name,
channel: t.channels?.[0], // ← channels[] → channel
status: t.status,
body: undefined, // ← SDK has no rendered body
category: t.category,
language: t.language,
}));
}
async getTemplate(id: string): Promise<TemplateItem> {
const response = await this.client.templates.retrieve(id);
const t = response.data ?? {};
return {
id: t.id, name: t.name, channel: t.channels?.[0], status: t.status,
body: undefined, category: t.category, language: t.language,
};
}# app/services/sent_service.py
def _channel_from_template(tmpl) -> str | None:
channels = getattr(tmpl, "channels", None)
return channels[0] if channels else None
async def list_templates(self) -> CanonicalTemplateList:
response = await self._client.templates.list(page=1, page_size=100)
templates = response.data.templates if response.data and response.data.templates else []
return CanonicalTemplateList(
items=[
CanonicalTemplate(
id=t.id, name=t.name,
channel=_channel_from_template(t), # ← channels[] → channel
status=t.status,
body=getattr(t, "body", None), # ← usually None
category=t.category, language=t.language,
)
for t in templates
]
)
async def get_template(self, template_id: str) -> CanonicalTemplate:
response = await self._client.templates.retrieve(template_id)
t = response.data
return CanonicalTemplate(
id=getattr(t, "id", None), name=getattr(t, "name", None),
channel=_channel_from_template(t), status=getattr(t, "status", None),
body=getattr(t, "body", None),
category=getattr(t, "category", None), language=getattr(t, "language", None),
)// internal/models/contract.go — map SDK template → contract shape.
func NewTemplateResponse(t sentdm.Template) TemplateResponse {
channel := ""
if len(t.Channels) > 0 {
channel = t.Channels[0] // ← channels[] → channel
}
return TemplateResponse{
ID: t.ID,
Name: t.Name,
Channel: channel,
Status: t.Status,
Body: "", // ← SDK carries no rendered body
Category: t.Category,
Language: t.Language,
}
}Looking up templates by id or name
The send API accepts a template id or name — you don't need both. Prefer resolving by
name and letting templates.list give you the id when you need it, rather than pasting UUIDs
around your codebase.
// Resolve a stable, human-readable name to whatever the current template is.
async function resolveTemplate(sent: SentService, name: string): Promise<TemplateItem> {
const templates = await sent.listTemplates();
const match = templates.find((t) => t.name === name);
if (!match) throw new ApiError(404, 'TemplateNotFound', `No template named "${name}"`);
return match;
}Don't ship hard-coded template UUIDs scattered through your business logic. Inlining one
(templateId: 'welcome-template-id') is fine to keep a snippet short — that's a
smell, not a pattern. In real code, map a small set of stable names (welcome,
order_update) to templates, resolve them at the edge, and let the id float. When a template
is re-approved with a new id, nothing in your code changes.
Validate at send time, too: a template must be approved for the channel you're sending on.
Check status === "APPROVED" (and that the resolved channel matches your intended channel)
before you call send, so you fail with a clear error instead of a cryptic API rejection.
Budget real time for approval before your go-live date. WhatsApp template approval typically
takes 24-48 hours (see Create your first template) —
SMS templates aren't gated the same way, so if you're SMS-only you can usually send immediately.
Build and test against a sandbox: true send while waiting; don't let approval latency be a
surprise on launch day.
resolveTemplate as shown makes a full listTemplates round trip on every send — fine for a
first pass, but templates change rarely, so a short-TTL cache (30-60s in memory, or your shared
store once you're on multiple instances) removes that extra call and its rate-limit cost from
your hot send path without meaningfully delaying a template re-approval from taking effect.
Why the wrapper matters
Every method here does the same three things: call one SDK resource, map snake→camel, translate errors. That uniformity is the point.
- Controllers depend on your service interface, never on SDK types — so the SDK is mockable and swappable in tests.
- The contract stays stable even when the SDK's field names or shapes drift.
- The client is still built per request (
servicesForRequest) from the bearer key — these reads are just as scoped as the sends. See Authentication.
Next steps
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.
Error Handling & Resilience
Turn SDK exceptions into your own error envelope, catch the right exception types per language, and build in retries, rate-limit backoff, idempotency, and timeouts.