---
title: "Receiving Webhooks"
source: https://sumgenius.ai/docs/receiving-webhooks
generated: 2026-07-30
---

# Receiving Webhooks

Everything between an event happening and your server trusting it: endpoints, delivery and retries, dead letters, signature verification, and how much data each event carries.

Webhook & API Add-on · $29/month · Requires Creator+ plan

## Endpoints

Configured in the portal under **Integrations → Webhook & Send API**. What lands where is entirely per endpoint: each has its own URL, event filters, signing secret, payload profile, and delivery settings.

### Your endpoints

You can run up to **3 endpoints**, for example production, staging, and a partner system. Every event is delivered independently to each enabled endpoint whose filters match it.

- urlHTTPS only Up to 2,048 characters. Must resolve to a public address: localhost, private, and reserved IP ranges are rejected, both when you save and again at delivery time.
- event filters Pick which [event types](https://sumgenius.ai/docs/webhook-events) this endpoint receives. No selection means all of them.
- signing secretper endpoint Each endpoint has its own 64-character secret, shown in the portal and rotatable at any time. See [Verifying signatures](#verify).
- payload profilefull | minimal How much customer data this endpoint receives. See [Payload profiles](#payload-profiles).
- data-sharing declaration Before an endpoint can be enabled you declare what the destination system is and why it receives customer messages, and attest to it. The declaration is per endpoint, so a partner system and your own backend each carry their own.
- timeout · attempts Response timeout defaults to 10 seconds (3 to 30) and retry attempts default to 6 (1 to 10), each adjustable per endpoint.

> **Note**
>
> New endpoints start **disabled** so you can verify the secret and send test events before real traffic flows. Test events are available per endpoint from the portal.

**Your endpoints via GET /api/v1/account**

```
{
  "webhooks": {
    "endpoints": [
      {
        "id": 7,
        "name": "Production",
        "url": "https://api.example.com/webhooks",
        "enabled": true,
        "event_filters": ["message.received", "lead.email_captured"],
        "envelope_style": "standard"
      },
      {
        "id": 9,
        "name": "Staging",
        "url": "https://staging.example.com/webhooks",
        "enabled": false,
        "event_filters": [],
        "envelope_style": "standard"
      }
    ]
  }
}
```

## Delivery

### Delivery & retries

Events arrive as an HTTPS `POST` with a JSON body, typically within seconds of the event. Any **2xx** response inside your endpoint's timeout counts as delivered; anything else is retried on a backoff schedule with ±10% jitter. At the default 6 attempts, retries span roughly 18 hours.

| Attempt | Delay after failure |
| --- | --- |
| 1 | immediate |
| 2 | ~5 seconds |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
| 6 | ~5 hours |
| 7+ | ~10 hours each (if attempts raised above 6) |

- delivery idstable across retries Every attempt for an event carries the same id (`webhook-id`, and `X-SumGenius-Delivery-Id` on legacy-signed deliveries). Deduplicate on it: a retry of something you already processed should be acknowledged with a 2xx and skipped.
- 410 Gone Responding 410 stops delivery of that event immediately, no further retries.

> **Tip**
>
> **Acknowledge fast, process async.** Return 200 as soon as you have durably queued the event, then do the real work. Slow handlers hit the timeout and get retried, which shows up as duplicates on your side.

**A delivery, on the wire**

```
POST /webhooks HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: SumGenius-Webhook/1.0
webhook-id: a9b8c7d6e5f40312a1b2c3d4e5f60718
webhook-timestamp: 1753848000
webhook-signature: v1,K6mGq0XcJ9...

{
  "type": "message.received",
  "timestamp": "2026-07-30T04:00:00+00:00",
  "data": { ... }
}
```

**Ack fast**

```
// queue first, work later
app.post('/webhooks', (req, res) => {
  queue.push(req.body);
  res.sendStatus(200);
});
```

### Dead letters & the delivery log

An event that exhausts its retries (or hits a permanent failure like a 410 or an invalid URL) becomes a **dead letter**. Nothing is silently dropped: the portal's Delivery Log records every attempt with its HTTP status, a snippet of your endpoint's response, and the request duration.

- Retry Re-queue a dead letter for delivery, individually or up to 50 at once. Retried events keep their original delivery id.
- Dismiss Acknowledge a dead letter you do not intend to redeliver. It leaves the actionable dead-letter count but stays in the log and still counts in the success-rate stats.

> **Note**
>
> Events whose body was purged by [Inbound Body Purge](#inbound-body-purge) cannot be redelivered; their Retry action is disabled.

**What the log records per attempt**

```
attempt      3
status       503
response     "upstream unavailable"
duration     742 ms
next retry   "in ~30 minutes"
```

## Security

### Verifying signatures

Every delivery is signed with the endpoint's own secret using HMAC-SHA-256. Verify before trusting a request; anyone can POST JSON at a public URL.

**Which scheme you have:** endpoints created since July 2026 sign in the [Standard Webhooks](https://www.standardwebhooks.com/) format (`webhook-id` / `webhook-timestamp` / `webhook-signature` headers). Integrations set up before then receive **both** header sets on every delivery, the standard ones and the original `X-SumGenius-*` ones, so existing verifiers keep working and you can migrate to the standard check whenever you like.

Standard scheme

- signed string `webhook-id + "." + webhook-timestamp + "." + raw body`
- signature `"v1," + base64(HMAC-SHA-256(signed string, secret))`. The header can hold several space-separated signatures; the delivery is valid if *any* matches. Your secret is the 64-character string from the portal, used as-is.
- timestamp Unix seconds. Reject deliveries older than about 5 minutes to prevent replays.

Legacy scheme

- headers `X-SumGenius-Signature`, `X-SumGenius-Timestamp`, `X-SumGenius-Event`, `X-SumGenius-Delivery-Id`
- signature `"sha256=" + hex(HMAC-SHA-256(timestamp + "." + raw body, secret))`

> **Note**
>
> **Rotation grace.** When you rotate an endpoint's secret, the previous secret stays valid for 24 hours. Standard-signed deliveries carry signatures from both secrets during that window, which is exactly why you verify against "any signature matches". Update your stored secret within the day and nothing ever fails.

Always verify against the **raw request body**, byte for byte, before any JSON parsing, and compare with a constant-time function.

**PHP**

```php
$secret  = getenv('SUMGENIUS_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$id      = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$ts      = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';

// reject stale deliveries (~5 min)
if (abs(time() - (int)$ts) > 300) { http_response_code(400); exit; }

$expected = 'v1,' . base64_encode(hash_hmac(
    'sha256', $id . '.' . $ts . '.' . $rawBody, $secret, true
));

$valid = false;
foreach (explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '') as $sig) {
    if (hash_equals($expected, $sig)) { $valid = true; }
}
if (!$valid) { http_response_code(401); exit; }

http_response_code(200);  // ack, then process async
```

**Node / Express**

```javascript
const crypto = require('crypto');

app.post('/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const id = req.headers['webhook-id'];
    const ts = req.headers['webhook-timestamp'];
    if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);

    const expected = 'v1,' + crypto
      .createHmac('sha256', process.env.SUMGENIUS_WEBHOOK_SECRET)
      .update(`${id}.${ts}.${req.body}`)
      .digest('base64');

    const valid = (req.headers['webhook-signature'] || '')
      .split(' ')
      .some(sig => sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));

    if (!valid) return res.sendStatus(401);
    res.sendStatus(200);  // ack, then process async
  });
```

**Legacy check (PHP)**

```
$expected = 'sha256=' . hash_hmac(
    'sha256',
    $_SERVER['HTTP_X_SUMGENIUS_TIMESTAMP'] . '.' . $rawBody,
    $secret
);
$valid = hash_equals($expected, $_SERVER['HTTP_X_SUMGENIUS_SIGNATURE'] ?? '');
```

## Data

### Payload profiles

Each endpoint chooses how much customer data its events carry. The active profile is stamped on every event as `meta.payload_profile`.

- fulldefault Everything: customer display name and username, phone and profile name on SMS and WhatsApp, CRM enrichment (`contact_id`, `tags`, `stage`), conversation assignment, attachment URLs and captions.
- minimal Keeps ids and message text for routing; drops the customer fields above (everything except `platform_user_id`), conversation assignment, and per-attachment `url`, `title`, and `caption` (type and media ids remain).

Not profile-gated, deliberately: `message.text_raw`, message and delivery ids, the channel sub-blocks, and the `lead` and `appointment` blocks. A lead event exists to hand you contact info, so [lead payloads](https://sumgenius.ai/docs/webhook-events#lead-captured) are always complete.

**Same event, minimal profile**

```
{
  "customer": {
    "platform_user_id": "17845629031224067"
  },
  "message": {
    "id": "m_mid.4823",
    "text_raw": "Do you ship to Kyiv?",
    "attachments": [
      {
        "type": "image",
        "media_type": "image/jpeg",
        "media_id": "18043762291",
        "received_at": "2026-07-30T04:00:00+00:00"
      }
    ]
  },
  "meta": {
    "version": "v1",
    "payload_profile": "minimal",
    "generated_at": "2026-07-30T04:00:01+00:00"
  }
}
```

## Relay modes

Two optional modes for integrations where your system, not ChatGenius, is in charge of replies.

## Global External Reply Mode

By default, ChatGenius AI and automation can still respond to incoming messages even when webhook forwarding is active. **Global External Reply Mode** applies a runtime override across all connected platforms, Facebook DM, Instagram DM, SMS, WhatsApp, and Telegram, so your external system becomes the sole responder.

### When to use it

- You have your own AI or support system and want full control over every response on every platform
- You need to route different conversation types to different handlers
- You want ChatGenius purely as a message delivery and forwarding layer

### What it does

- Pauses ChatGenius AI and automation replies on Facebook DM, Instagram DM, SMS, WhatsApp, and Telegram, while keeping all inbound forwarding, signing, retries, conversation storage, and the Send API fully working
- Auto-claims new incoming conversations to your account (across all platforms) so they appear as customer-managed in the portal
- Does **not** change your saved AI Assistant or Automation toggles in Settings. It is a runtime override that flips off when you disable External Reply Mode

### How to enable

Toggle **Global External Reply Mode** in your portal under **Integrations → Webhook & Send API**. The change takes effect on the very next inbound message.

### Per-platform fine-grained control

If you want AI on for some platforms but off for others (for example, AI for DMs but pure-relay for SMS and WhatsApp), leave Global External Reply Mode off and use each platform's individual AI toggle:

- **Facebook / Instagram DMs**: AI Assistant toggle in *Settings → AI Configuration*
- **SMS**: AI Auto-Responses in *Settings → SMS*
- **WhatsApp**: WhatsApp AI Assistant toggle in *Settings → WhatsApp*
- **Telegram**: Telegram AI Assistant toggle in *Settings → Telegram*

Any individual toggle being off pauses AI for that platform; Global External Reply Mode does the same thing for every platform at once: pause AI, keep forwarding.

> **Warning**
>
> When Global External Reply Mode is on, ChatGenius will not auto-respond to **any** inbound message. Make sure your webhook endpoint is live and your external system is ready to handle every conversation before flipping this on.

## Inbound Body Purge After Forward

An optional per-account toggle for compliance scenarios where **your server is the system of record** and ChatGenius should not retain message bodies after forwarding. Designed for healthcare-adjacent and privacy-strict deployments.

> **Warning**
>
> **Pure-relay only, incompatible with ChatGenius AI.** This toggle clears the conversation context that AI uses to compose replies (recent message history, batched user inputs, follow-up scheduling state, active flow variables). With AI Assistant on, enabling this would cause incomplete or broken replies. **Required configuration:** SMS AI off, OR [Global External Reply Mode](#external-reply-mode) on (which pauses AI across every connected platform). Use this only when your server handles every reply.

### What it does

> **Note**
>
> **SMS only.** Automatic body purge applies to inbound `sms.message_received` events only. Other channels, WhatsApp, Facebook DM, Instagram DM, and Telegram, are never auto-purged, regardless of this toggle.

When enabled, after we forward an inbound SMS message to your endpoint, we scrub:

- The message body text from stored conversation history
- The forwarded event's `message.text_raw` and `message.attachments` fields
- The staging queue entries holding the raw inbound SMS

### What we retain

For audit and routing purposes:

- **Message ID** (Twilio MessageSid)
- **Timestamp**
- **Sender identifier** (E.164 phone number)
- **Direction** (inbound)
- **Delivery status** (delivered, failed, etc.)
- **Ticket reference**: if your endpoint returns a top-level `ticket_ref` string in its 2xx response body, we capture it. If missing or malformed, we still purge; the 2xx status is the only purge trigger.

### One-shot delivery semantics

When the toggle is on, the forwarder uses **max_attempts = 1** for SMS inbound events. We make exactly one HTTP attempt to your endpoint (with your endpoint's timeout) and then purge the body regardless of outcome, success or failure. This keeps the in-database lifetime tight for compliance.

### Inbox impact

SMS threads will appear in the ChatGenius unified inbox as **metadata-only entries**: message ID, timestamp, sender, direction, delivery status, ticket reference. The message body is not displayed (because it is not stored). The inbox itself remains active so you can connect other channels (WhatsApp, Facebook DM, Instagram DM, Telegram) without reconfiguration.

### Outbound message retention

Outbound message bodies (messages your server sends through us) are retained for **90 days** for delivery troubleshooting, then automatically cleared for accounts using this compliance mode.

### Backups

The nightly backup pipeline uses sanitized shadow tables for the body-bearing tables before the dump is compressed and replicated. For opted-in accounts, inbound SMS bodies are removed from backup data even if the live forward attempt is still pending when the snapshot starts. Metadata remains backup-recoverable, including message IDs, timestamps, sender identifiers, delivery state, and ticket references. Backup retention is 14 days local plus 14 days on Cloudflare R2.

### Enabling it

Before flipping the toggle on, disable SMS AI, either:

- **Per platform**: turn off *AI Auto-Responses* in *Settings → SMS*.
- **Or globally**: enable [Global External Reply Mode](#external-reply-mode).

Then in **Integrations → Webhook & Send API**, flip *Purge inbound message body after successful forward (SMS only)*. Owner-only. Default off. The change applies on the next inbound message.

### Why these dependencies

The purger scrubs inbound bodies from conversation history, the webhook event queue, and the SMS staging table. ChatGenius AI normally pulls conversation history from those exact stores to compose replies, batch rapid-fire user inputs, schedule follow-ups, and drive flow logic. If the purger fires while AI is also running on the same conversation, AI's working memory disappears mid-stream and replies become incomplete or wrong. Disabling AI on SMS (or globally) ensures AI never tries to use what is about to be purged.

> **Warning**
>
> Once a body has been purged from a delivered or dead-letter event, you cannot redeliver that event from ChatGenius. The Retry buttons in the Delivery Log are disabled for purged events. Make sure your endpoint persists what it needs before responding 2xx.
