# Retayl WhatsApp Send API

One endpoint. Every caller — mobile app, website, admin panel, crons — posts
here with a lifetime API key instead of calling Meta directly. That is what
makes per-template billing possible: Meta's pricing callback names the
category but never the template, so send time is the only moment the template
is knowable.

```
POST https://shvosm.com/retayl/api/send.php
X-Api-Key: rk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

The key also works as `Authorization: Bearer rk_live_...` or an `api_key`
field, matching the convention already used by `admin/fcm/notification_api.php`.

## Send a template

```
action=send
to=9820012345                 10 digits is fine; 91 is added automatically
template=birthday
language=gu                   only needed if the name exists in two languages
params[name]=Preet Shah       named style
params[]=Preet Shah           positional style — both accepted
number=1217486651445027       optional; overrides the default sender
dedupe_key=birthday:4821:2026 optional; makes the call idempotent
source=admin:birthdays        optional label for the log
dry_run=1                     validate and price, send nothing
```

`params` may be a map or an ordered list, and the API converts it to whatever
style the template was approved with. That is stored per template as
`param_style` — a named template sent without `parameter_name` fails with
code 100 "Parameter name is missing or empty", and a positional one sent with
it fails too. Registering the style once means no caller has to know.

**Response**

```json
{ "ok": true,
  "message_id": "wamid.HBgM...",
  "ledger_id": 10482,
  "billing": { "kind":"template", "template":"birthday",
               "rate":0.15, "amount":0.15, "currency":"INR",
               "rate_source":"template", "provisional":true } }
```

`provisional` is the important word. See *Why amounts change* below.

## Send a service message

Free-form text, only valid inside the 24-hour window after the contact wrote
to you. Needs the `service` scope on the key.

```
action=send&kind=service&to=9820012345&text=Your card is ready
```

## Other actions

| action | what it does |
|---|---|
| `ping` | checks the key, returns the client and scopes |
| `templates` | templates this key may send, their parameters, and whether each is priced |
| `usage` | this month's counts, spend, and service allowance per WABA |
| `status` | one message by `message_id` or `dedupe_key` |

## Errors

Always `{"ok":false,"error":"...","error_code":"..."}` with a real HTTP status.

| `error_code` | meaning |
|---|---|
| `unauthorised` | key missing, revoked, or called from a non-allowlisted IP |
| `forbidden` | key lacks the scope (`send`, `service`, `read`) |
| `unknown_template` | not registered in the portal for this client |
| `ambiguous_template` | exists in two languages, pass `language` |
| `unpriced_template` | **no rate set — the send is refused** |
| `bad_params` | wrong number of parameters, or a blank one |
| `bad_recipient` | `to` is not a usable number |
| `send_failed` | Meta rejected it; `meta_code` carries Meta's own code |

### Unpriced templates are refused, not sent free

Rates are per template and strict. A template with no rate in force cannot be
sent — it would be either revenue quietly lost or a ₹0 line on a client's
bill. Register the template and set its rate in the portal first. `dry_run=1`
tells you before you depend on it.

## Why amounts change after sending

The amount in the send response is what the rate card says. Whether the
message is actually chargeable is Meta's call, and it only arrives later on
the status webhook:

```json
"pricing": { "billable": false, "pricing_model": "PMP",
             "category": "utility", "type": "free_customer_service" }
```

A utility template delivered inside an open service window is **free**, and
nothing on our side can predict it. So the webhook corrects the ledger row
afterwards, in four directions:

- Meta says not billable → amount becomes 0
- the message failed → amount becomes 0
- we assumed free under the service allowance but Meta charged → amount
  restored, and the row flagged `meta_charged_despite_allowance`
- we charged but Meta says free → amount zeroed

Invoices are built from the corrected figures. In the first two months of your
log, 247 of 6,069 messages were free this way — including 71 utility templates.
A flat per-template rate would have billed for all of them.

## Service message allowance

Meta gives 1,000 free service messages per **WABA** per calendar month. You
have two WABAs, so in practice 2,000. `retayl_service_rates.allowance_scope`
decides whether the client gets that or a pooled 1,000:

- `waba` — each number its own 1,000 (what Meta actually does)
- `client` — pooled, Retayl keeps the difference

Our own count and Meta's billable count are both stored, per WABA per month,
in `retayl_service_usage`. They should agree. **This is untested against real
data** — you have never exceeded 1,000 (176 service messages in two months) —
which is why both numbers are kept rather than trusting one. The first month
you cross the line, check them.

## Calling it from PHP

```php
function waSend(array $fields): array {
    $ch = curl_init('https://shvosm.com/retayl/api/send.php');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => ['X-Api-Key: ' . RETAYL_KEY],
        CURLOPT_POSTFIELDS     => $fields,   // form-encoded; params[...] works
    ]);
    $out = curl_exec($ch);
    curl_close($ch);
    return json_decode($out, true) ?: ['ok' => false, 'error' => 'No response'];
}

$r = waSend([
    'action'        => 'send',
    'template'      => 'birthday',
    'language'      => 'gu',
    'to'            => $member['whatsapp'],
    'params[name]'  => $member['full_name'],
    'dedupe_key'    => 'birthday:' . $member['id'] . ':' . date('Y'),
    'source'        => 'admin:birthdays',
]);
```

Keep the key in one include, not in each caller.

## Senders still to migrate

Found in your code so far — each needs pointing at this endpoint, and each
needs its template registered with a rate first:

- `verify_otp.php` — OTP login (the 1,446 authentication messages)
- Halari Card application notifications (utility)
- `bookings.php` per-row send buttons
- `birthday_send.php` — already records its template, easiest to move first
- `send_message.php` — manual inbox replies, these are `kind=service`

`openwa_send.php` is the older OpenWA/PM2 integration, not Cloud API, so Meta
does not bill it and it stays outside this system.

---

## Two databases

Retayl's tables live in their own database; the client's platform data stays
where it is. Same MySQL server, same user, one PHP connection — so
cross-database joins work normally and the existing client code is untouched.

```
`shvosm_retayl`   rates, API keys, message ledger, invoices
`shvosm_shvosm`   whatsapp_numbers, whatsapp_chat, halari_applications
```

**Create it in cPanel → MySQL Databases** as `retayl` (cPanel prefixes it to
`shvosm_retayl`), then **add the same MySQL user to it with ALL PRIVILEGES**.
Without that grant nothing runs, and the API answers with
`error_code: db_unreachable` rather than a confusing missing-table error.

The database name lives in exactly one place:

```php
// lib/retayl_config.php
define('RETAYL_DB', 'shvosm_retayl');
```

In the code, Retayl tables carry the prefix and client tables do not, because
the connection's default database is the client's:

```php
$T = rtp();   // `shvosm_retayl`.
$conn->prepare("SELECT m.amount, n.label
                  FROM {$T}retayl_messages m
                  JOIN whatsapp_numbers n ON n.phone_number_id = m.phone_number_id
                 WHERE m.client_id = ?");
```

If Retayl ever needs its own credentials, this is the only file that changes —
`rtp()` becomes a second connection and the SQL stays as it is.
