# Guide to Agora Webhooks: Features and Best Practices

Agora powers live voice and video inside other people's apps, and Agora Notifications is how your backend learns what is happening in those calls and streams. When a channel is created or torn down, a host goes live, an audience member drops off or a viewer is promoted to co-host, Agora posts a small JSON callback to your server. Teams use it to keep a presence list accurate, bill by minutes spent in a channel, or close a session in their own database when the last user leaves.

This guide covers the RTC event codes, how the console health check works, how to verify the `Agora-Signature` header, and why an Agora receiver has to expect repeated and out-of-order callbacks.

## What are Agora webhooks?

Agora calls the feature Notifications, often shortened to NCS. You enable it per project in Agora Console, choose the product and the events you care about, and give Agora an HTTPS URL. Agora then "calls your webhook endpoint from its servers" with an `HTTPS POST` whenever a subscribed event happens.

Every callback shares one envelope, whatever the product:

| Field | Meaning |
| --- | --- |
| `noticeId` | The notification's ID. It stays the same when Agora resends |
| `productId` | The product: `1` is RTC, `3` Cloud Recording, `4` Media Pull, `5` Media Push |
| `eventType` | A number that identifies the event within that product |
| `notifyMs` | When the notification was sent, in milliseconds. Updated on every resend |
| `payload` | The event's own fields, which vary with `eventType` |

Event codes only mean something alongside `productId`, so route on both. This guide covers the RTC codes (`productId` 1). Cloud Recording, Media Pull and Media Push document their own codes on their own pages.

## Agora webhook features

| Feature | Details |
| --- | --- |
| Configuration | Agora Console, per project: **Webhooks > New Webhook** |
| Subscription | Choose a product, a region and the event types to receive |
| Signature | hex HMAC-SHA1 |
| Signature header | `agora-signature` |
| Second signature | `Agora-Signature-V2`, an HMAC-SHA256 of the same body. Agora does not document its encoding |
| Signed content | The raw request body only |
| Body | JSON |
| Event routing | Body field `eventType`, scoped by `productId` |
| Response window | `200` within 10 seconds |
| Retries | Resent immediately, then at growing intervals, at most three retries |
| Ordering | Not guaranteed. Duplicates are possible by design |
| Source IPs | Published through an authenticated API, and they change |

## Events and payloads

| `eventType` | Meaning | Handled in the linked skill examples |
| --- | --- | --- |
| `101` | Channel create | Yes |
| `102` | Channel destroy | Yes |
| `103` | Broadcaster joins channel | Yes |
| `104` | Broadcaster leaves channel | Yes |
| `105`, `106`, `107`, `108`, `111`, `112` | Other documented RTC codes | Logged only |

| Documented event | Meaning |
| --- | --- |
| `101` | Initializes the channel. |
| `102` | Destroys the channel. |
| `103` | In the streaming profile, the host joins the channel. |
| `104` | In the streaming profile, the host leaves the channel. |
| `105` | In the streaming profile, an audience member joins the channel. |
| `106` | In the streaming profile, an audience member leaves the channel. |
| `107` | In the communication profile, a user joins the channel. |
| `108` | In the communication profile, a user leaves the channel. |
| `111` | In the communication profile in RTC v4.x products or in the streaming profile, an audience member switches their user role to host. |
| `112` | In the communication profile in RTC v4.x products or in the streaming profile, a host switches their user role to audience member. |

The codes split by channel profile. A streaming (live broadcast) channel produces `103` to `106` for hosts and audience members, while a communication channel produces `107` and `108`. The event names in the table ("channel create", "broadcaster join channel") are documentation labels only. On the wire you get the number, as a JSON number rather than a string.

Every channel event except `101` and `102` carries `payload.clientSeq`, a sequence number Agora provides so that you can put events for a user back in order. Agora publishes per-event examples of the inner `payload` but no complete envelope, so log a few real callbacks before you finalise your parser.

## Setting up Agora webhooks

Your endpoint needs to be live before you save, because Agora tests it as part of setup.

1. In Agora Console, open your project and go to **Webhooks**, then **New Webhook**.
2. Choose the product (**RTC** for the events above) and the region.
3. Enter your HTTPS receiving URL, for example `https://example.com/webhooks/agora`. Agora only calls HTTPS endpoints.
4. Select the event types to receive. Subscribe only to the codes you handle.
5. Optionally, enable the IP whitelist option if your firewall needs it.
6. Select **Run health check**. Agora sends test events with the channel name `test_webhook` and the user ID `12121212`, and your endpoint must answer `200` within 10 seconds.
7. Copy the **Signing Secret** and store it server-side, for example as `WEBHOOK_SECRET`. It is used as plain text, not hex- or base64-decoded.

Filter out the health check traffic in your handler by its channel name, so `test_webhook` never ends up in your presence data or your billing.

To test locally, forward Agora's callbacks with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 agora --path /webhooks/agora

```

## Securing Agora webhooks

Agora adds two signature headers to every callback, both computed over the raw request body with the Signing Secret as the key. `Agora-Signature` is a lowercase hex HMAC-SHA1, and Agora's Go sample shows exactly how it is encoded. `Agora-Signature-V2` is an HMAC-SHA256, but Agora shows no sample or encoding for it, so the code below verifies the SHA1 header. Compare in constant time. See the [verification documentation](https://docs.agora.io/en/realtime-media/rtc/build/optimize-and-operate/receive-notifications#add-signature-verification).

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['agora-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha1', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{40}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Mount `express.raw({ type: '*/*' })` on the route so the body stays as bytes. The handler below verifies, skips health check traffic, records the `noticeId` so resends are ignored, and hands the event to a queue before answering. A queue failure returns `503` instead of a `200` that would lose the event.

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

function createAgoraApp({ seenNotice, enqueue }) {
  const app = express();
  app.post('/webhooks/agora', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let notice;
    try {
      notice = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!notice || typeof notice !== 'object' || Array.isArray(notice)) return res.sendStatus(400);
    if (!notice.noticeId) return res.sendStatus(400);
    if (notice.payload?.channelName === 'test_webhook') return res.status(200).json({});

    try {
      if (await seenNotice(notice.noticeId)) return res.status(200).json({});
      // RTC codes are only unique within productId 1.
      if (notice.productId === 1 && [101, 102, 103, 104, 105, 106, 107, 108, 111, 112].includes(notice.eventType)) {
        await enqueue({ noticeId: notice.noticeId, eventType: notice.eventType, payload: notice.payload });
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.status(200).json({});
  });
  return app;
}

```

Agora signs nothing but the body. `notifyMs` changes on each resend and is not signed separately, so there is no timestamp to check for freshness.

Hookdeck's Agora source type verifies the same `Agora-Signature` header, a hex HMAC-SHA1 of the raw body, with your Signing Secret. Hookdeck answers Agora with `200` whatever the outcome, records a failed check as failed verification and does not forward that request to your destination. Retries and replays from Hookdeck verify whenever they run, because no time limit is applied.

> **Make Agora webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Agora inside its 10-second window, verifies `Agora-Signature` on every callback, and keeps each channel event so you can retry or replay it after your own service has an outage.

## Agora webhook limitations and pain points

### Duplicates and out-of-order delivery by design

**The Problem:** A user appears to leave a channel before joining it, or the same join is counted twice.

**Why It Happens:** Agora states that notifications can be duplicated and can arrive out of order. Resends of a missed callback arrive after newer events, and the network does not preserve sequence across separate requests.

**Workarounds:**

* Deduplicate on `noticeId`, which stays the same across resends.
* Order a user's events by `payload.clientSeq` rather than by arrival time or `notifyMs`.
* Model presence as state that you reconcile, not as a stream of increments.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated callbacks with the same `noticeId` before they reach your service, and its event log shows the exact order in which each callback arrived.

### Three retries, then the event is gone

**The Problem:** A short outage on your side leaves permanent gaps in channel history.

**Why It Happens:** Agora resends a failed notification immediately and then at growing intervals, and stops after three retries. A deploy or a slow database can exhaust that budget.

**Workarounds:**

* Answer `200` within 10 seconds and do the work afterwards.
* For billing, reconcile channel usage against Agora's own usage reports rather than relying only on callbacks.

**How Hookdeck Can Help:** Hookdeck accepts each callback as it arrives and stores it, then retries delivery to your service on a schedule you set, so your downtime no longer counts against Agora's three retries.

### An IP allowlist you have to keep refreshing

**The Problem:** A firewall rule that worked last month starts dropping callbacks.

**Why It Happens:** Agora does not list its Notifications IPs on a page. You fetch them from an authenticated API (`GET https://api.agora.io/v2/ncs/ip`), and Agora says it adjusts them occasionally and that you should query at least every 24 hours.

**Workarounds:**

* Rely on signature verification as the main control, and treat IP filtering as an extra layer.
* If you do filter by IP, refresh the list from the API on a schedule and alert when it changes.

**How Hookdeck Can Help:** With Hookdeck in front, Agora only needs to reach Hookdeck's endpoint, and your own firewall only needs to admit Hookdeck.

### The SHA256 header is underspecified

**The Problem:** Code written to verify `Agora-Signature-V2` may never match.

**Why It Happens:** Agora documents that V2 is an HMAC-SHA256 of the same body but publishes no sample or encoding for it. Only the SHA1 header has a worked example.

**Workarounds:**

* Verify `Agora-Signature`, which is fully documented.
* If your policy requires SHA256, capture a real callback and confirm the V2 encoding before relying on it.

**How Hookdeck Can Help:** Hookdeck's Agora source type verifies the documented SHA1 header for you.

## Best practices

### Keep the raw body until verification passes

The HMAC covers the exact bytes Agora sent. Parsing and re-serialising the JSON changes whitespace and key order, and the digest will not match. Use `express.raw()` in Express, `await request.body()` in FastAPI, or `Buffer.from(await request.arrayBuffer())` in a Next.js route handler.

### Route on `productId` and `eventType` together

If one endpoint receives RTC and Cloud Recording callbacks, the same `eventType` number can mean different things. Check `productId` first, then switch on the code.

### Ignore the health check channel

Treat `channelName` `test_webhook` as test traffic. It is sent each time you run the console health check and should never update production state.

### Reply quickly, with a JSON body

Agora counts a callback as delivered when it gets `200 OK` within 10 seconds of sending it. Return `200` with a small JSON body as soon as the event is stored, and process it asynchronously, for the reasons in [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make side effects idempotent

Store each `noticeId` in the same transaction as the change it causes, so a resend cannot double-count minutes or presence. Our [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) covers the patterns.

## Conclusion

Agora's callbacks are small and frequent, and the hard part is not the HMAC but the delivery model: three retries, no ordering and deliberate duplicates. A receiver that verifies the SHA1 header, keys on `noticeId` and orders by `clientSeq` will give you an accurate picture of who is in each channel.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of that receiver, verifies each callback and keeps every channel event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Agora webhooks reliably in minutes.