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:
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 theIdempotency-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 withPOST /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-Customerso 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: setsend_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 returns402 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-Customerfor the right tenant - Fresh
Idempotency-Keyper logical send; same UUID on retries - Templates outside the 24-hour window; check
service_windowwhen 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
402as a billing state to show the tenant, not an error to retry
Related
- Build a platform — customer-per-tenant model
- Messages — types and the service window
- Idempotency — header behaviour in depth
- Broadcasts — paced segment sends
- Webhooks — delivery and signatures