Problem Context

Reactive systems thrive on "something happened, who cares?" โ€” a blob was uploaded, a resource was created, a custom domain event fired. You don't want every producer to know who's listening, and you don't want every consumer to poll. That's the gap Azure Event Grid fills: a hyper-scale, push-based eventing service with strong delivery semantics and the new Namespace tier that adds MQTT broker capabilities and pull delivery in 2024-2026.

It's not a queue (Service Bus does that) and not a streaming firehose (Event Hubs does that). Event Grid is for discrete state-change events โ€” small JSON envelopes, fan-out to subscribers, retries with exponential backoff and dead-lettering.

๐Ÿค” Sound familiar?
  • You wrote a Function that polls Storage every 30 seconds for new blobs
  • You can't explain the difference between Event Grid Topics, System Topics, Domains, and Namespaces
  • Your webhook subscriber stopped receiving events and you didn't know retries had been exhausted
  • You picked Event Grid for high-throughput telemetry and watched cost spiral

This guide is the 2026 mental model โ€” when to pick Event Grid (vs Service Bus / Event Hubs), how to wire push and pull, and the new Namespace tier with MQTT.

Concept Explanation

Three event sources, four delivery patterns:

  • System Topicsโ€” emitted by Azure resources (Storage, Key Vault, AKS, Resource Groups). You don't create the topic, you subscribe to it.
  • Custom Topics โ€” your apps publish their own events to a topic you create.
  • Domains โ€” a multi-tenant container of topics; useful when you have 100s of customer-scoped topics with shared subscribers.
  • Namespaces (newer tier) โ€” adds pull delivery (consumer-initiated), MQTT v3.1.1/v5 broker, and event-stream patterns.

flowchart LR
    A["Storage Account<br/>(BlobCreated)"] --> ST["System Topic"]
    B["Custom App<br/>(POST /events)"] --> CT["Custom Topic"]
    M["MQTT Client<br/>(IoT device)"] --> NS["Namespace<br/>(MQTT broker)"]

    ST --> S1["Subscription:<br/>Function"]
    CT --> S2["Subscription:<br/>Webhook"]
    CT --> S3["Subscription:<br/>Service Bus Queue"]
    NS --> S4["Pull subscription:<br/>Worker polls"]

    S2 -.->|delivery fails<br/>retries exhausted| DLQ["Dead-letter to<br/>Storage Blob"]

    style ST fill:#0078D4,color:#fff,stroke:#005a9e
    style CT fill:#0078D4,color:#fff,stroke:#005a9e
    style NS fill:#7c3aed,color:#fff,stroke:#5b21b6
    style DLQ fill:#dc2626,color:#fff,stroke:#b91c1c

Schema: CloudEvents v1.0 is the default (JSON envelope with specversion, type, source,id, data). The legacy "Event Grid schema" still works on Topics created earlier; new code should target CloudEvents.

Implementation

Step 1: System Topic โ€” react to BlobCreated with a Function

param storageAccountId string
param functionId string

resource sysTopic 'Microsoft.EventGrid/systemTopics@2024-12-15-preview' = {
  name: 'sys-storage-blobs'
  location: 'global'
  properties: {
    source: storageAccountId
    topicType: 'Microsoft.Storage.StorageAccounts'
  }
}

resource sub 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2024-12-15-preview' = {
  parent: sysTopic
  name: 'to-thumbnail-fn'
  properties: {
    eventDeliverySchema: 'CloudEventSchemaV1_0'
    filter: {
      includedEventTypes: ['Microsoft.Storage.BlobCreated']
      subjectBeginsWith: '/blobServices/default/containers/uploads/'
    }
    destination: {
      endpointType: 'AzureFunction'
      properties: {
        resourceId: functionId
        maxEventsPerBatch: 1
        deliveryAttributeMappings: []
      }
    }
    retryPolicy: { maxDeliveryAttempts: 30, eventTimeToLiveInMinutes: 1440 }
    deadLetterDestination: {
      endpointType: 'StorageBlob'
      properties: { resourceId: storageAccountId, blobContainerName: 'eg-deadletter' }
    }
  }
}

Step 2: Custom Topic โ€” publish your own events

using Azure.Identity;
using Azure.Messaging;
using Azure.Messaging.EventGrid;

var endpoint = new Uri("https://my-topic.eastus2-1.eventgrid.azure.net/api/events");
var client = new EventGridPublisherClient(endpoint, new DefaultAzureCredential());

var evt = new CloudEvent(
    source: "/aiwisdom/orders",
    type: "com.aiwisdom.order.placed",
    jsonSerializableData: new { orderId = order.Id, total = order.Total })
{
    Subject = $"customer/{order.CustomerId}",
    Time = DateTimeOffset.UtcNow,
    Id = Guid.NewGuid().ToString(),
};

await client.SendEventAsync(evt);

Step 3: Webhook subscriber โ€” handle the validation handshake

app.MapPost("/eventgrid", async (HttpRequest req) =>
{
    using var doc = await JsonDocument.ParseAsync(req.Body);

    // Subscription validation (only for old EG-schema; CloudEvents uses OPTIONS preflight)
    if (req.Headers.TryGetValue("aeg-event-type", out var t) && t == "SubscriptionValidation")
    {
        var code = doc.RootElement[0].GetProperty("data").GetProperty("validationCode").GetString();
        return Results.Ok(new { validationResponse = code });
    }

    // Process events (batch may contain many)
    foreach (var evt in doc.RootElement.EnumerateArray())
    {
        var type = evt.GetProperty("type").GetString();
        await dispatcher.HandleAsync(type, evt.GetProperty("data"));
    }
    return Results.Ok();
})
.WithRequestTimeout(TimeSpan.FromSeconds(30)); // EG expects 200 within 30s

For CloudEvents schema, instead of the inline SubscriptionValidation message, Event Grid sends an HTTP OPTIONSpreflight with WebHook-Request-Origin. Respond with WebHook-Allowed-Origin: eventgrid.azure.net and a 200.

Step 4: Advanced filters โ€” keep handlers focused

filter: {
  includedEventTypes: ['com.aiwisdom.order.placed']
  advancedFilters: [
    { operatorType: 'NumberGreaterThanOrEquals', key: 'data.total', value: 1000 }
    { operatorType: 'StringIn',                 key: 'data.region', values: ['us-east','eu-west'] }
    { operatorType: 'IsNullOrUndefined',        key: 'data.archived' }
  ]
}

Step 5: Namespace + Pull delivery โ€” when push doesn't fit

// Namespace topics support pull (REST/SDK ReceiveCloudEvents)
using Azure.Messaging.EventGrid.Namespaces;

var client = new EventGridReceiverClient(
    new Uri("https://myns.eastus-1.eventgrid.azure.net"),
    topicName: "orders",
    subscriptionName: "warehouse",
    credential: new DefaultAzureCredential());

ReceiveResult result = await client.ReceiveAsync(maxEvents: 10, maxWaitTime: TimeSpan.FromSeconds(30));
foreach (var detail in result.Details)
{
    try
    {
        await Process(detail.Event);
        await client.AcknowledgeAsync(new[] { detail.BrokerProperties.LockToken });
    }
    catch
    {
        await client.ReleaseAsync(new[] { detail.BrokerProperties.LockToken });
    }
}

Pitfalls

1. Picking Event Grid for telemetry. Event Grid bills per operation and per delivery attempt. Pumping millions of small events through it costs more than Event Hubs and gives you weaker ordering guarantees. Reserve Event Grid for state-change notifications.

2. Webhook handler that takes > 30s. Event Grid expects a 2xx within 30 seconds or it considers the delivery failed and retries. Acknowledge fast, queue work asynchronously.

3. Forgetting the validation handshake. First message to a new webhook is always a validation. If your handler 401s or 500s on it, the subscription never activates.

4. No DLQ configured. Without a dead-letter destination, exhausted retries silently drop events. Always setdeadLetterDestination to a Storage container and alert on its growth.

5. Coupling business logic to Event Grid schema. Use CloudEvents and parse type/data through your own DTOs. The Azure SDK gives you CloudEvent deserializers โ€” use them.

6. Conflating Service Bus, Event Hubs, and Event Grid. Discrete state changes โ†’ Event Grid. Workflow messages โ†’ Service Bus. Telemetry/log streams โ†’ Event Hubs. They are not interchangeable.

Practical Takeaways

  • Default schema is CloudEvents v1.0. Use it for all new topics.
  • Always configure a dead-letter Storage container and alert on object count.
  • Subscribers must respond within 30 seconds with 2xx โ€” process asynchronously if work is slow.
  • Use advanced filters server-side to avoid waking up handlers that won't do anything.
  • Function and Service Bus destinations are first-class โ€” prefer them over raw webhooks (no validation handshake, retries handled).
  • Use Namespaces when you need MQTT, pull delivery, or higher per-subscription ordering than push gives you.
  • Auth via Entra and managed identity. EventGridPublisherClient(endpoint, credential) works without keys.