# Guide to Azure Event Grid Webhooks: Features and Best Practices

Azure Event Grid delivers events from Azure services and your own applications to a webhook endpoint you control: a blob is created, a resource is deleted, a subscription is validated. If you're routing Azure events to your own service, webhooks are how they arrive.

This guide covers how Event Grid webhooks work, the two endpoint validation handshakes, how to authenticate deliveries when nothing is signed, and the best practices for production.

## What are Azure Event Grid webhooks?

Event Grid is a managed pub/sub service rather than a single-vendor webhook sender, and that changes what "verifying a webhook" means. Event Grid does not sign the request body. Trust comes from two places instead. First, an ownership handshake at subscription time proves you control the endpoint before Event Grid sends anything. Second, a credential you configure yourself authenticates each delivery: a header, a query parameter, or a Microsoft Entra ID token.

## Azure Event Grid webhook features

| Feature | Details |
| --- | --- |
| Configuration | Azure portal, `az eventgrid event-subscription create`, ARM/Bicep |
| Signature header | None. Event Grid signs nothing |
| Endpoint validation | `Microsoft.EventGrid.SubscriptionValidationEvent` echo, or a CloudEvents `OPTIONS` preflight |
| Delivery authentication | Static delivery-property header, query-parameter client secret, or Microsoft Entra ID bearer token |
| Delivery headers | `aeg-subscription-name`, `aeg-delivery-count`, `aeg-event-type`, `aeg-metadata-version`, `aeg-data-version`, `aeg-output-event-id` |
| Payload shape | Event Grid schema (JSON array) or CloudEvents v1.0 (single JSON object) |
| Transport | HTTPS only |
| Delivery | At-least-once, unordered, 30-second response timeout |

## Common events

Event Grid is a broker, so most event types belong to the Azure service publishing them rather than to Event Grid itself. Custom topics carry whatever types you define.

| Event | Fires when |
| --- | --- |
| `Microsoft.Storage.BlobCreated` / `Microsoft.Storage.BlobDeleted` | A blob is written or removed |
| `Microsoft.Resources.ResourceWriteSuccess` / `ResourceDeleteSuccess` | An Azure resource is created, updated or deleted |
| `Microsoft.ContainerRegistry.ImagePushed` | A container image is pushed |
| `Microsoft.EventGrid.SubscriptionValidationEvent` | An event subscription is created or updated |
| `Microsoft.EventGrid.SubscriptionDeletedEvent` | An event subscription is deleted |
| `Microsoft.EventGrid.MQTTClientSessionConnected` | An MQTT client session connects to an Event Grid namespace |

The envelope depends on the delivery schema you chose. CloudEvents v1.0 gives you a single JSON object with `specversion`, `type`, `source`, `id`, `time`, `subject` and `data`. Event Grid schema gives you a JSON array whose elements carry `eventType`, `eventTime`, `topic`, `subject`, `data`, `dataVersion` and `metadataVersion`.

> See Azure Event Grid webhook payloads in action. Inspect and replay sample Event Grid payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up Azure Event Grid webhooks

Create an event subscription against a topic, system topic or domain, pointing at your HTTPS endpoint:

```bash
az eventgrid event-subscription create \
  --name my-webhook-subscription \
  --source-resource-id "$TOPIC_ID" \
  --endpoint "https://example.com/webhooks/azure-event-grid" \
  --endpoint-type webhook \
  --event-delivery-schema cloudeventschemav1_0

```

The `--event-delivery-schema` flag decides both your payload shape and which handshake you'll get, so choose it before you write the handler. Event Grid only delivers to HTTPS endpoints, and self-signed certificates aren't supported for validation.

## Securing Azure Event Grid webhooks

There is no signature to verify, so securing an Event Grid endpoint is two jobs: answer the right handshake, and check a credential on every delivery.

### The two handshakes

Which one fires depends on the delivery schema, and they are alternatives rather than a sequence.

Event Grid schema sends a POST whose body is a single-element array containing a `Microsoft.EventGrid.SubscriptionValidationEvent`. Echo `data.validationCode` back:

```javascript
const VALIDATION = "Microsoft.EventGrid.SubscriptionValidationEvent";

app.post("/webhooks/azure-event-grid", express.json(), (req, res) => {
  const events = Array.isArray(req.body) ? req.body : [req.body];

  // Only validate subscriptions you created. Otherwise anyone who learns this
  // URL can point their own subscription at it and self-validate.
  const subscription = req.get("aeg-subscription-name");
  if (!EXPECTED_SUBSCRIPTIONS.includes(subscription)) return res.sendStatus(403);

  const validation = events.find((e) => e.eventType === VALIDATION);
  if (validation) {
    // Must be 200. Event Grid does not accept 202 for the handshake.
    return res.status(200).json({ validationResponse: validation.data.validationCode });
  }

  res.sendStatus(200); // acknowledge fast
  events.forEach((e) => queue.add(e)); // branch on eventType, dedupe on id, async
});

```

CloudEvents v1.0 replaces that with an HTTP `OPTIONS` abuse-protection preflight. Consent is signalled by the response headers, not the status code:

```javascript
app.options("/webhooks/azure-event-grid", (req, res) => {
  const origin = req.get("WebHook-Request-Origin");
  if (!origin) return res.sendStatus(400);

  res.set("WebHook-Allowed-Origin", origin); // or "*"
  res.set("WebHook-Allowed-Rate", "120");
  res.sendStatus(200);
});

```

### Authenticating each delivery

The handshake proves you own the endpoint once. It does nothing about someone replaying requests at your URL afterwards, which is why you also need a credential. The simplest is a static delivery-property header you set on the subscription and compare in constant time:

```python
import hmac
import os

# Comma-separated so you can accept the old and new secret during rotation.
ACCEPTED = [s for s in os.environ["EVENT_GRID_SECRETS"].split(",") if s]

def check_secret(received: str) -> bool:
    if not ACCEPTED:
        return False  # fail closed: "unset" must never mean "allow anything"
    # No early return, so timing doesn't reveal which secret matched.
    matched = False
    for candidate in ACCEPTED:
        if hmac.compare_digest(received or "", candidate):
            matched = True
    return matched

```

`hmac.compare_digest` here is a constant-time string comparison, not an HMAC. Nothing is being hashed, because there is nothing to hash.

> Make Azure Event Grid webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) answers the handshake, checks your credential, deduplicates, and durably queues every Azure event.

## Azure Event Grid webhook limitations and pain points

### There is no signature

The Problem: Developers reach for a familiar HMAC verification template and write a `crypto.createHmac` check against a header Event Grid never sends. The handler then rejects every delivery, or silently accepts anything.

Why It Happens: Almost every other webhook provider signs its payloads, so the absence is easy to miss.

Workarounds:

* Don't write a signature verifier. Answer the handshake and check a configured credential instead.

How Hookdeck Can Help: Hookdeck supports Event Grid's authentication methods without pretending there's a signature to check.

### `202` acknowledges a delivery but fails the handshake

The Problem: Event Grid treats `200`, `201`, `202`, `203` and `204` as successful deliveries, but the validation response must be `200`. A handler that returns `202` everywhere passes deliveries and never completes validation.

Why It Happens: The two paths have different rules, and Microsoft documents them on separate pages.

Workarounds:

* Return `200` specifically for the validation response, within 30 seconds.

How Hookdeck Can Help: Hookdeck answers the handshake with the exact response Event Grid expects.

### Handshake failure is total and silent

The Problem: If validation never completes, the subscription is never activated and no event is ever sent. There's no failed delivery in any log, because nothing was delivered. You're debugging an absence.

Why It Happens: Validation gates activation, so a broken handshake produces silence rather than errors.

Workarounds:

* Check the subscription's provisioning state. `AwaitingManualAction` means you returned 200 without echoing the code; GET the `validationUrl` within 10 minutes to finish manually. That URL uses port 553, which firewalls routinely block.

How Hookdeck Can Help: Hookdeck completes the handshake for you, and every delivery attempt is visible in the request log.

### Two payload shapes, two discriminators

The Problem: Event Grid schema delivers a JSON array keyed on `eventType`. CloudEvents delivers a single object keyed on `type`. A handler written for one breaks on the other, and the choice is per-subscription configuration rather than a default you can assume.

Why It Happens: Event Grid supports both its own proprietary format and the CloudEvents standard.

Workarounds:

* Normalise both shapes on the way in, and always loop, because batching can put up to 5,000 events in one array.

How Hookdeck Can Help: Hookdeck's transformations normalise either shape before your service sees it.

### Rotating a query-parameter secret drops deliveries

The Problem: If you carry the secret in the endpoint URL's query string, updating your service and the subscription are two separate steps. Deliveries fail in the gap.

Why It Happens: The secret lives in the subscription's endpoint URL, which only Azure can change.

Workarounds:

* Accept the old and new secret concurrently, update the subscription, then retire the old one. Note that query parameters aren't returned when you read a subscription back unless you pass `--include-full-endpoint-url`.

How Hookdeck Can Help: Hookdeck holds the credential at the edge, so rotating it doesn't touch your application.

## Best practices

### Answer the handshake your delivery schema actually uses

Implement both paths if you support both schemas, and gate them on `aeg-subscription-name` so an attacker who learns your URL can't self-validate.

### Return 200 for validation, and acknowledge fast

Event Grid waits 30 seconds for a response before queueing a retry. Return quickly and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Authenticate every delivery, and fail closed

Compare your credential in constant time, and reject when it's unconfigured rather than accepting anything.

### Dedupe on the event id

Delivery is at-least-once and unordered, so duplicates and out-of-order events are normal. `aeg-delivery-count` tells you which attempt you're on. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Azure Event Grid webhooks have no payload signature. Security rests on completing the right endpoint validation handshake, either the `SubscriptionValidationEvent` echo for Event Grid schema or the CloudEvents `OPTIONS` preflight, and then authenticating every delivery with a header, query-parameter secret or Entra ID token you configure yourself. Return `200` for validation specifically, guard on `aeg-subscription-name`, handle both payload shapes, and dedupe on the event `id`.

[Hookdeck](https://hookdeck.com) answers the handshake, checks your credential, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.

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