# Guide to RudderStack Webhooks: Features and Best Practices

RudderStack is a customer data platform. It collects events from websites, mobile apps, cloud tools and warehouses, and routes them to the analytics, marketing and storage tools you connect. Its HTTP Webhook destination sends those same events to an endpoint you run, which is how you feed RudderStack data into an internal service that has no ready-made integration.

This guide covers how the HTTP Webhook destination shapes its requests, the message types you receive, how to authenticate requests with HTTP Basic credentials, and the practices that keep a receiver reliable in production.

## What are RudderStack webhooks?

In RudderStack, a webhook is a destination. You connect the **HTTP Webhook** destination to one or more sources, and RudderStack sends each event from those sources to your URL in cloud mode. Web, mobile, server-side, cloud, warehouse and Shopify sources are all supported.

The destination is configurable, so the request your endpoint receives depends on the settings: the method, the body format, whether events are sent as is or mapped, whether they are batched, and which authentication type is used. RudderStack does not sign requests. Of its four authentication types, Basic Auth is the one Hookdeck's RudderStack source checks.

RudderStack also has an older destination called **Webhook**, which only supports custom headers for authentication. This guide covers the HTTP Webhook destination.

## RudderStack webhook features

| Feature | Details |
| --- | --- |
| Configuration | HTTP Webhook destination, connected to one or more sources |
| Base URL | HTTP or HTTPS. HTTPS needs a valid TLS certificate. Path and query parameters can use JSONPath |
| Method | `POST` by default. `PUT`, `PATCH`, `GET` and `DELETE` are also available |
| Body format | JSON, XML or form-encoded |
| Body content | The RudderStack event as is (default), or fields you map with JSONPath |
| Batching | JSON only. An array of up to 100 events, sent when full or after 5 seconds |
| Headers | Custom headers with constant keys, plus `user-agent: RudderLabs` on every request |
| Authentication | No Auth, Basic Auth, Bearer Token or API Key. No signature |
| Failure visibility | The destination's **Live Events** tab, with the error response |

## Message types

With **Send the event payload as is** turned on, which is the default, the body is the RudderStack event and its `type` field says what kind it is.

| `type` | Meaning | Key fields |
| --- | --- | --- |
| `track` | A user action | `event` (the action's name), `properties` |
| `identify` | Who the user is | `userId`, `traits` |
| `page` | A web page view | `name`, `properties` |
| `screen` | A mobile screen view | `name`, `properties` |
| `group` | A user joining a company or account | `groupId`, `traits` |
| `alias` | Two user identities merged | `userId`, `previousId` |

The destination also accepts `record` and `audienceList` messages from warehouse and audience sources.

Every event carries common fields such as `messageId`, `anonymousId`, `userId`, `timestamp`, `context` and `integrations`. Here is an illustrative `track` event, shortened to show the fields a receiver usually reads:

```json
{
  "type": "track",
  "event": "Order Completed",
  "messageId": "f6a1b0e2-2c4d-4b7a-9d3e-0a1b2c3d4e5f",
  "anonymousId": "8d872292709c6fbe",
  "userId": "user_123",
  "timestamp": "2026-10-22T09:15:27.000Z",
  "properties": {
    "order_id": "ORD-1001",
    "revenue": 49.99,
    "currency": "GBP"
  },
  "context": {
    "library": { "name": "RudderLabs JavaScript SDK" }
  },
  "integrations": { "All": true }
}

```

Three settings change that shape:

* **Mappings.** If you turn off **Send the event payload as is**, the body contains only the fields you map, and `type` is missing unless you map it.
* **Batching.** With batching on, the body is a JSON array of events, `[{...}, {...}]`, rather than one object.
* **Body format.** XML and form bodies are not JSON, and a `GET` request has no body at all.

> **Inspect RudderStack webhooks as they arrive.** Set a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as the destination's Base URL to see the exact method, headers and body your settings produce, with no account or setup required.

## Setting up RudderStack webhooks

Pick a username and generate a long random password, for example with `openssl rand -hex 32`, and store both on your receiver.

1. In the RudderStack dashboard, add a source if you don't have one, then add the **HTTP Webhook** destination and give it a name.
2. Set **Base URL** to your HTTPS endpoint.
3. Under **Authentication**, choose **Basic Auth** and enter the username and password.
4. Leave **Method** as `POST` and **Body Format** as JSON unless your endpoint needs something else.
5. Optionally add path and query parameters, custom headers, mappings and batching.
6. Connect the destination to your sources, send a test event, and check the **Live Events** tab for failures.

RudderStack builds the `Authorization: Basic` header itself and sends it on every request, so there is no challenge round trip.

To put Hookdeck in front of your service, create a Hookdeck source with the **RudderStack** type and set its URL as the Base URL. Enter the same **Username** and **Password** on the source. Hookdeck still answers with a `200`, but a request with missing or wrong credentials is recorded as failed verification and never reaches your destination. The source has `POST` selected. If you change the destination's method to `PUT`, `PATCH`, `GET` or `DELETE`, allow that method on the Hookdeck source as well. If you choose Bearer Token or API Key in RudderStack instead of Basic Auth, this source won't verify the request.

## Securing RudderStack webhooks

With Basic Auth, each request carries `Authorization: Basic base64(username:password)`. Decode it, split on the first colon, and compare the username and password with your stored values in constant time.

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

function safeEqual(a, b) {
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyBasicAuth(header, username, password) {
  if (!username || !password) return false; // never accept when unconfigured
  const match = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(header || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64');
  const colon = decoded.indexOf(0x3a); // split on the first colon: passwords may contain ':'
  if (colon === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, colon), Buffer.from(username, 'utf8'));
  const passOk = safeEqual(decoded.subarray(colon + 1), Buffer.from(password, 'utf8'));
  return userOk && passOk; // evaluate both before deciding
}

const app = express();

app.post('/webhooks/rudderstack', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyBasicAuth(
    req.get('authorization'),
    process.env.RUDDERSTACK_WEBHOOK_USERNAME,
    process.env.RUDDERSTACK_WEBHOOK_PASSWORD
  );
  if (!ok) return res.sendStatus(401);

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // With batching on, the body is an array of events.
  const events = Array.isArray(body) ? body : [body];
  for (const event of events) {
    // Skip event.messageId if you have seen it, then route on event.type.
    switch (event.type) {
      case 'track':
      case 'identify':
        break;
      default:
        // Log and acknowledge anything else.
    }
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac
import os
import re

BASIC_PATTERN = re.compile(r"^basic\s+([A-Za-z0-9+/]+={0,2})\s*$", re.IGNORECASE)

def verify_basic_auth(header, username, password) -> bool:
    if not username or not password:
        return False  # never accept when unconfigured
    match = BASIC_PATTERN.match(header or "")
    if not match:
        return False
    try:
        decoded = base64.b64decode(match.group(1), validate=True)
    except (binascii.Error, ValueError):
        return False
    received_user, sep, received_password = decoded.partition(b":")  # first colon only
    if not sep:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    user_ok = hmac.compare_digest(received_user, username.encode("utf-8"))
    password_ok = hmac.compare_digest(received_password, password.encode("utf-8"))
    return user_ok and password_ok

# verify_basic_auth(request.headers.get("authorization"),
#                   os.environ.get("RUDDERSTACK_WEBHOOK_USERNAME"),
#                   os.environ.get("RUDDERSTACK_WEBHOOK_PASSWORD"))

```

Basic credentials prove the sender knows the password. They do not cover the body and carry no timestamp, so a modified or replayed request still passes. RudderStack accepts plain HTTP Base URLs, and over HTTP the credentials and your users' data travel in clear text, so always use HTTPS. Use a long random password and rotate it in both places if it might have leaked.

> **Make RudderStack webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Basic credentials, absorbs traffic spikes, and delivers each event to your service at a rate it can handle.

## RudderStack webhook limitations and pain points

### The request shape depends on settings

**The Problem:** A handler that worked yesterday fails after someone turns on batching, edits a mapping or changes the body format.

**Why It Happens:** The method, body format, mappings and batching are all destination settings that anyone with access can change, and each one changes what your endpoint receives.

**Workarounds:**

* Accept both a single object and an array, as the example above does.
* Return `400` for a body you can't parse, so the failure shows in **Live Events** instead of being silently dropped.
* Agree who owns the destination settings and review changes to them.

**How Hookdeck Can Help:** Hookdeck stores every request with its method, headers and body, so you can see exactly when the shape changed. [Transformations](/docs/transformations) can normalise single events and batches into one shape before they reach your service.

### Event volume follows your traffic

**The Problem:** A campaign or a busy day sends a surge of `track` and `page` events, and your endpoint slows down or falls over.

**Why It Happens:** RudderStack forwards events as fast as sources produce them. Batching cuts the request count but not the number of events.

**Workarounds:**

* Turn on batching to send up to 100 events per request.
* Acknowledge quickly and process from a queue.
* Drop message types you don't need before they reach your endpoint.

**How Hookdeck Can Help:** Hookdeck accepts the surge, queues it, and delivers to your service at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you set. [Filters](/docs/filters) can drop message types you don't use before they reach your service.

### Retry behaviour is not on the destination page

**The Problem:** You don't know how long RudderStack keeps retrying when your endpoint is down, or when it gives up.

**Why It Happens:** The HTTP Webhook setup guide documents the Live Events tab for failures, but not the retry schedule or request timeout.

**Workarounds:**

* Watch **Live Events** and alert on errors from your endpoint.
* Keep your endpoint's response time low so a slow response isn't treated as a failure.

**How Hookdeck Can Help:** Hookdeck accepts and stores each request as it arrives, then retries delivery to your service on a schedule you choose with [retries](/docs/retries). You can replay events after an outage, and [issues](/docs/issues) alert you when deliveries fail.

### Mapped bodies lose the event type

**The Problem:** With custom mappings, the handler can no longer tell a `track` from an `identify`.

**Why It Happens:** When **Send the event payload as is** is off, only mapped fields are sent.

**Workarounds:**

* Map `$.type`, `$.event` and `$.messageId` into the body, or into the path or query string.

## Best practices

### Deduplicate on `messageId`

Every RudderStack event has a `messageId`. Use it as the idempotency key so a retried request or a replayed batch doesn't count an event twice. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle each event in a batch on its own

When batching is on, one bad event shouldn't fail the whole request. Validate and queue events one at a time, and record the ones you skip.

### Order by `timestamp`, not arrival

Events can arrive late, especially from mobile devices that were offline. Use the event's `timestamp` when ordering or updating state.

### Acknowledge fast

Return `200` once the request is authenticated and process it from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

RudderStack's HTTP Webhook destination forwards customer events to your endpoint, and its settings decide the method, body format, mappings and batching. Use Basic Auth over HTTPS, compare the credentials in constant time, accept both single events and batches, and deduplicate on `messageId`.

[Hookdeck Event Gateway](https://hookdeck.com) checks the Basic credentials, smooths out traffic spikes, and keeps every event until your service has processed it.

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