Gareth Wilson Gareth Wilson

Guide to Agora Webhooks: Features and Best Practices

Published


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:

FieldMeaning
noticeIdThe notification's ID. It stays the same when Agora resends
productIdThe product: 1 is RTC, 3 Cloud Recording, 4 Media Pull, 5 Media Push
eventTypeA number that identifies the event within that product
notifyMsWhen the notification was sent, in milliseconds. Updated on every resend
payloadThe 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

FeatureDetails
ConfigurationAgora Console, per project: Webhooks > New Webhook
SubscriptionChoose a product, a region and the event types to receive
Signaturehex HMAC-SHA1
Signature headeragora-signature
Second signatureAgora-Signature-V2, an HMAC-SHA256 of the same body. Agora does not document its encoding
Signed contentThe raw request body only
BodyJSON
Event routingBody field eventType, scoped by productId
Response window200 within 10 seconds
RetriesResent immediately, then at growing intervals, at most three retries
OrderingNot guaranteed. Duplicates are possible by design
Source IPsPublished through an authenticated API, and they change

Events and payloads

eventTypeMeaningHandled in the linked skill examples
101Channel createYes
102Channel destroyYes
103Broadcaster joins channelYes
104Broadcaster leaves channelYes
105, 106, 107, 108, 111, 112Other documented RTC codesLogged only
Documented eventMeaning
101Initializes the channel.
102Destroys the channel.
103In the streaming profile, the host joins the channel.
104In the streaming profile, the host leaves the channel.
105In the streaming profile, an audience member joins the channel.
106In the streaming profile, an audience member leaves the channel.
107In the communication profile, a user joins the channel.
108In the communication profile, a user leaves the channel.
111In the communication profile in RTC v4.x products or in the streaming profile, an audience member switches their user role to host.
112In 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:

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.

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.

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 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.

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 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 sits in front of that receiver, verifies each callback and keeps every channel event until your service has processed it.

Get started with Hookdeck for free and handle Agora webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.