Skip to main content
Messaging is the write path every tenant uses daily — and the surface where integration mistakes hurt most, because the failure mode is visible on your customer’s WhatsApp number. The recipe: create with an Idempotency-Key, upload media once, schedule with send_at instead of cron storms, and learn outcomes from webhooks.

Send on a tenant’s behalf

You already have everything from the core model: the customer’s id / external_id, and either a scoped key or a platform key. Send exactly as in the quickstart, scoped to them:
Tenant isolation on send is your job when using a platform key. The key can act for any customer you pass in X-LazyApp-Customer. Only ever set that header (or use a scoped key) for the tenant that owns the action.
Outside the 24-hour window you must send a template. Check service_window on GET /v1/contacts/{contact} before choosing text vs template.

Make creation retry-safe

Your tenants’ messages flow through queues, workflow tools, and mobile clients that all retry on timeouts — and a duplicate WhatsApp message goes to their contact, not yours. Use the Idempotency-Key header on every write: The working recipe: generate a fresh UUID per logical send, reuse the same UUID when retrying that exact call, and treat a replayed response as success.

Upload media once, reference it everywhere

Upload with POST /v1/media (or POST /v1/media/url), then pass media_id on the message. Two multi-tenant rules keep this efficient:
  • One upload per asset. A tenant sending the same PDF to many contacts reuses one media_id.
  • Scope uploads to the tenant. Use the scoped key or X-LazyApp-Customer so media lands in their workspace — otherwise you cannot attach it to their send.

Schedule without cron storms

The tempting design — your scheduler fires every tenant’s 9:00 reminders with immediate sends at 9:00 sharp — creates a thundering herd against your rate limit and Meta’s pacing. Prefer LazyApp scheduling: set send_at (and optional ttl_seconds) so each message is accepted as 202 and released when due:
For bulk template sends to a segment, use broadcasts — LazyApp paces them to the number’s messaging tier.

Billing refusals

A send refused for billing returns 402 with a message, and a code when there is one to branch on. Retrying the same request will not help, so show the tenant the reason and stop: On a customer-paid number — the default — Meta bills the business directly and LazyApp charges nothing per message, so the spending limit and empty-wallet refusals never trigger. A prepaid customer at zero is still refused, though, which is why customer-paid customers belong on inherit. See How platform billing works.

Outcomes come from webhooks

The create response means accepted — status: "sent" (or a scheduled id for 202). What happens next arrives on message webhooks: Route events via customer.external_id, then update your UI from status: You can also GET /v1/messages/{uuid} for confirmation — prefer webhooks in production.
Don’t poll conversations or messages to discover status changes. Webhooks push the outcome; polling is only for reconciliation.

Checklist

  • Scope every send with a customer key or X-LazyApp-Customer for the right tenant
  • Fresh Idempotency-Key per logical send; same UUID on retries
  • Templates outside the 24-hour window; check service_window when you can
  • Upload media in the tenant’s workspace; reuse media_id
  • Prefer send_at / broadcasts over synchronized cron storms
  • Status from webhooks routed by customer.external_id — not from polling
  • Treat 402 as a billing state to show the tenant, not an error to retry