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

# Webhooks and signatures

> Get message, campaign and account events as they happen.

Add an https link in the app (**Developer → Webhooks**) or with `POST /v1/webhooks`, and pick the events you want. We post each event to your link as JSON. Copy the **signing secret** when it is shown: you see it once.

## The events

| Event | When |
| - | - |
| `message.sent` | A message left WhatsApp's servers for the person. |
| `message.delivered` | It reached the person's phone. |
| `message.read` | The person opened it. |
| `message.failed` | It could not be sent or delivered. |
| `message.received` | Someone sent you a message, or tapped a button. |
| `wallet.low` | Your wallet went below ₹500. |
| `number.quality_changed` | Your number's quality changed. |
| `number.status_changed` | Your number's status changed. |
| `number.name_changed` | Your display name was decided. |
| `template.status_changed` | A template was approved, rejected, paused or disabled. |
| `template.quality_changed` | A template's quality changed. |
| `template.created` | A template was made (in the app or by the API). |
| `template.deleted` | A template was deleted (in the app or by the API). |
| `campaign.started` | A campaign started sending. |
| `campaign.finished` | A campaign finished. |
| `campaign.paused` | A campaign was paused. |
| `campaign.stopped` | A campaign was stopped, or could not go on. |
| `plan.ended` | Your plan ended. |
| `key.disabled` | An API key was switched off. |
| `daily_limit.reached` | You reached your daily messaging limit (told once in 24 hours). |
| `webhook.disabled` | One of your other webhooks was switched off after failing for 24 hours. |

## What we send

```json theme={null}
{
  "id": "evt_6f1c…",
  "type": "message.delivered",
  "created_at": "2026-10-22T19:41:08.120+05:30",
  "data": { "id": "…", "to": "+9198250*****", "status": "delivered", "price": "0.9131", "reference": "order-4521" }
}
```

`data` is the same as the API's own answer for that thing: a message event carries the message as `GET /v1/messages/{id}` shows it. Every message event (and `message.received`) also has `customer`: `{ "phone": "+9198250*****" | null, "user_id": "IN.1349…" | null, "username": "ramesh_k" | null }`. `phone` can be null: a customer who uses a WhatsApp username may not share their number. Headers: `X-GetReply-Event` (the type) and `X-GetReply-Signature`.

Message events also carry `campaign_id` and `campaign_reference` (null when the message is not part of a campaign), and what was sent: `text` and `media` (`id`, `type`, `mime_type`; null when no file). That includes replies your team sends from the Inbox.

## Account events

```json plan.ended theme={null}
{ "id": "evt_…", "type": "plan.ended", "created_at": "2026-11-19T00:00:41.000+05:30", "data": { "ended_at": "2026-11-19T00:00:00.000+05:30", "plan": "pro" } }
```

```json key.disabled theme={null}
{ "id": "evt_…", "type": "key.disabled", "created_at": "2026-10-26T11:20:05.310+05:30", "data": { "name": "Shop website", "prefix": "gr_live_4f2a", "reason": "manual", "disabled_at": "2026-10-26T11:20:05.310+05:30" } }
```

`reason` is `manual` (switched off in the app), `staff` (switched off by our team) or `plan` (your plan ended, or is not Pro).

```json daily_limit.reached theme={null}
{ "id": "evt_…", "type": "daily_limit.reached", "created_at": "2026-10-26T18:02:11.004+05:30", "data": { "limit": 1000, "counted": 1000, "reached_at": "2026-10-26T18:02:11.004+05:30" } }
```

Sent once in 24 hours, when no one new can be messaged today.

```json webhook.disabled theme={null}
{ "id": "evt_…", "type": "webhook.disabled", "created_at": "2026-10-26T09:15:00.000+05:30", "data": { "webhook_id": "cm8…", "url": "https://example.com/old-hook", "reason": "failing", "disabled_at": "2026-10-26T09:15:00.000+05:30" } }
```

Sent to your **other** webhooks that are still on, when one of yours is switched off after failing for 24 hours. Switch it on again with `POST /v1/webhooks/{id}/enable`: we send it a test event first, and it goes on only when your link answers with a 2xx.

`campaign.paused` also says why, in `reason`: `by_you`, `staff`, `whatsapp`, `wallet`, `quality`, `plan_ended`, `whatsapp_not_responding`, `number_deleted` or `number_paused`.

## Check the signature

`X-GetReply-Signature` looks like `t=1761142268,v1=5f2a…`. `v1` is the HMAC-SHA256 of `t`, a dot, and the **raw** body, with your secret. Right after you make a new secret there are two `v1` values: accept either. Check the time too, so an old request can't be sent again.

```javascript Node.js theme={null}
import crypto from "node:crypto";

// rawBody: the request body exactly as it arrived (a string), before any JSON parsing.
export function verify(rawBody, header, secret) {
  const parts = header.split(",");
  const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
  const given = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return given.some((g) => g.length === expected.length && crypto.timingSafeEqual(Buffer.from(g), Buffer.from(expected)));
}
```

```php PHP theme={null}
<?php
// PHP 8 or newer.
// $rawBody = file_get_contents('php://input');
function verify(string $rawBody, string $header, string $secret): bool {
  $t = 0; $given = [];
  foreach (explode(',', $header) as $part) {
    if (str_starts_with($part, 't=')) $t = (int) substr($part, 2);
    if (str_starts_with($part, 'v1=')) $given[] = substr($part, 3);
  }
  if ($t === 0 || abs(time() - $t) > 300) return false;
  $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
  foreach ($given as $g) if (hash_equals($expected, $g)) return true;
  return false;
}
```

```python Python theme={null}
import hashlib, hmac, time

# raw_body: the request body as bytes, exactly as it arrived.
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = header.split(",")
    t = next((int(p[2:]) for p in parts if p.startswith("t=")), 0)
    given = [p[3:] for p in parts if p.startswith("v1=")]
    if not t or abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, g) for g in given)
```

## Answer fast, then work

Answer with any **2xx** straight away, then do your work. Each event has an `id`: if the same `id` comes twice, handle it once.

* We wait up to 10 seconds for your answer. Any 2xx answer counts as delivered, and that event is never sent again.
* Anything else is tried again, 6 tries in all (see the table).
* A webhook that only fails for 24 hours is switched off. Switch it on again in Developer → Webhooks.
* After you make a new secret, the old one keeps signing for 24 hours.
* Every try is kept 7 days (Developer → Webhooks → Deliveries).

| Try | When |
| - | - |
| 1 | At once |
| 2 | 1 minutes after the try before |
| 3 | 5 minutes after the try before |
| 4 | 30 minutes after the try before |
| 5 | 2 hours after the try before |
| 6 | 6 hours after the try before |

A webhook that keeps failing is switched off. Turn it back on in the app: Developer → Webhooks → Switch on. Or use `POST /v1/webhooks/{id}/enable`.


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