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 single channel — take channels[0].
  • The SDK Template model carries no rendered body — emit null for 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

On this page