> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lazyapp.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenant messaging

> Send and schedule WhatsApp on behalf of every customer — retry-safe creation, media once, outcomes from webhooks.

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](https://docs.lazyapp.in/platform/overview.md): the customer's id / `external_id`, and either a scoped key or a platform key. Send exactly as in the [quickstart](https://docs.lazyapp.in/quickstart.md), scoped to them:

```bash cURL theme={null}
curl https://lazyapp.in/v1/messages \
  -H "Authorization: Bearer $LAZYAPP_API_KEY" \
  -H "X-LazyApp-Customer: acct_8812" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4b4986f4-77b5-4c22-a3a7-2c56f7a657e1" \
  -d '{
    "type": "template",
    "to": "+919812345670",
    "template": {
      "name": "appointment_reminder",
      "language": "en"
    }
  }'
```

```js Node theme={null}
await fetch('https://lazyapp.in/v1/messages', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.LAZYAPP_API_KEY}`,
    'X-LazyApp-Customer': customer.externalId,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(), // reuse on retry — see below
  },
  body: JSON.stringify({
    type: 'template',
    to: '+919812345670',
    template: { name: 'appointment_reminder', language: 'en' },
  }),
});
```

```python Python theme={null}
requests.post(
    "https://lazyapp.in/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['LAZYAPP_API_KEY']}",
        "X-LazyApp-Customer": customer.external_id,
        "Idempotency-Key": str(uuid.uuid4()),  # reuse on retry
    },
    json={
        "type": "template",
        "to": "+919812345670",
        "template": {"name": "appointment_reminder", "language": "en"},
    },
)
```

> [!WARNING]
>
> **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](https://docs.lazyapp.in/sending/templates.md). Check `service_window` on [`GET /v1/contacts/{contact}`](https://docs.lazyapp.in/sending/messages#service-window) 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](https://docs.lazyapp.in/concepts/idempotency) on every write:

| Layer | Keyed on | On replay |
| - | - | - |
| `Idempotency-Key` header | The UUID you send | Same status and body, with `Idempotency-Replayed: true` |
| Optional body `idempotency_key` | Domain-level dedupe on the message row | Refuses a second send with the same key |

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`](https://docs.lazyapp.in/sending/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:

```json theme={null}
{
  "type": "template",
  "to": "+919812345670",
  "template": { "name": "appointment_reminder", "language": "en" },
  "send_at": "2026-09-02T09:00:00+05:30",
  "ttl_seconds": 3600
}
```

| Instead of | Do this |
| - | - |
| Firing every tenant's sends at `:00` from your cron | `send_at` per message — LazyApp releases them when due |
| A create-message loop with no backoff for a CSV import | A worker with a concurrency cap; honour `429` |
| Polling for delivery | [Webhooks](https://docs.lazyapp.in/webhooks/overview.md) |

For bulk template sends to a segment, use [broadcasts](https://docs.lazyapp.in/sending/broadcasts.md) — 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:

| Cause | What to do |
| - | - |
| The customer is suspended | Restore it with `POST /v1/platform/customers/{customer}/restore` |
| A `prepaid` customer's wallet is at zero | Add to `wallet_balance_cents`, or move the customer to `inherit` |
| The customer reached `spending_limit_cents` this month | Raise the limit, or wait for the next month |
| The wallet paying a [partner-managed](https://docs.lazyapp.in/platform/overview.md#who-pays-meta) number is empty (`insufficient_credits`, `service_credits_exhausted`) | Add money to that wallet |

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](https://docs.lazyapp.in/platform/overview.md#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](https://docs.lazyapp.in/webhooks/events.md#messages):

```mermaid theme={null}
sequenceDiagram
  participant T as Tenant
  participant A as Your app
  participant L as LazyApp
  T->>A: triggers send
  A->>L: POST /v1/messages (Idempotency-Key, X-LazyApp-Customer)
  L-->>A: 201 — status sent, uuid
  alt delivered
    L-->>A: webhook message.delivered (customer envelope)
    A-->>T: show delivered
  else failed
    L-->>A: webhook message.failed
    A-->>T: surface error + offer retry
  end
```

Route events via `customer.external_id`, then update your UI from status:

| Status / event | Show the tenant |
| - | - |
| `sent` / `message.sent` | Accepted by WhatsApp |
| `message.delivered` | Reached the device |
| `message.read` / `message.played` | Opened / voice played |
| `message.failed` | The error, with a retry action |

You can also [`GET /v1/messages/{uuid}`](https://docs.lazyapp.in/api-reference/messages/retrieve-a-message) for confirmation — prefer webhooks in production.

> [!NOTE]
>
> 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

## Related

* [Build a platform](https://docs.lazyapp.in/platform/overview.md) — customer-per-tenant model
* [Messages](https://docs.lazyapp.in/sending/messages.md) — types and the service window
* [Idempotency](https://docs.lazyapp.in/concepts/idempotency.md) — header behaviour in depth
* [Broadcasts](https://docs.lazyapp.in/sending/broadcasts.md) — paced segment sends
* [Webhooks](https://docs.lazyapp.in/webhooks/overview.md) — delivery and signatures


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.