# Oban Alternatives for Webhook Retries: Hookdeck Event Gateway, Broadway, Cloud Queues, and Convoy Compared

Oban is so embedded in Elixir that it becomes the default tool for "webhook retries" the moment a Phoenix controller starts seeing 5xx responses or a Stripe rate limit. The pattern is canonical: verify the signature, `Oban.insert()` a job, return a 200. It's Postgres-backed, the enqueue can share a transaction with your own writes, and Oban Web is now free and open source.

Oban is one of the best general-purpose job systems on any stack, and the case for moving off it is weaker than for most queues. That's exactly why it's worth being precise about what it doesn't do. Webhook ingestion has requirements (signature verification, spike absorption, per-provider retry policy, event-level visibility) that live upstream of any job queue, however good. When webhooks become critical, those gaps surface in a predictable order: raw-body plumbing and HMAC code multiply across providers, Postgres becomes the write-path bottleneck for traffic you don't control, and when retries finally exhaust, `oban_jobs` shows you an errors array instead of the event you actually lost.

This article looks at the alternatives for the webhook-specific part of that workload: [Hookdeck Event Gateway](/event-gateway), Broadway with a message broker, cloud queues like Amazon SQS, and Convoy. None of these replace Oban wholesale, and they shouldn't. The question is which concerns belong upstream of your application, and what handles them best. Working in a different stack? See the equivalent guides for [Sidekiq in Rails](/webhooks/platforms/sidekiq-alternatives-for-webhook-retries), [Celery in Python](/webhooks/platforms/celery-alternatives-for-webhook-retries), [BullMQ in Node](/webhooks/platforms/bullmq-alternatives-for-webhook-retries), and [Laravel in PHP](/webhooks/platforms/laravel-queue-alternatives-for-webhook-retries).

## How to evaluate an Oban alternative for webhook retries

Webhook retries look like a queueing problem, but the evaluation criteria differ from general background jobs. Six dimensions matter:

* Webhook-aware semantics. Does the tool understand signatures, source identity, and event deduplication natively, or do you keep writing that logic in Elixir? Stripe uses HMAC-SHA256 with a timestamp tolerance, Shopify uses HMAC-SHA256 over the raw body, GitHub uses HMAC-SHA256 with its own header format. A generic queue treats all of this as your problem.
* Failed-payload visibility. When a job is discarded, can you see the original request (headers, body, timestamps, delivery attempts) or only a job-centric record? An `oban_jobs` row gives you `args` and an errors array. The headers are gone unless you stored them, and the row itself is pruned eventually.
* Per-source retry policy. Stripe retries for up to three days on its side; GitHub doesn't automatically retry at all; Shopify cancels your webhook subscription after enough consecutive failures. A sensible retry policy differs per provider. Can you change it without a deploy?
* Operational footprint. What carries the load? With Oban it's Postgres: every webhook is an insert, state transitions, and index churn on `oban_jobs`, plus the Pruner and connection pool tuning that keep it healthy.
* Elixir ecosystem fit. Does the alternative require rewriting controllers and workers, or does it sit upstream and hand your existing Phoenix route pre-verified events?
* Cost. Oban itself is free (Oban Pro is commercial), so the cost is mostly indirect: Postgres capacity sized for ingestion spikes, and the engineering hours that go into maintaining webhook plumbing instead of product.

Keep these in mind as we go through what Oban actually gives you.

## What Oban does (and why Elixir developers reach for it on webhooks)

Oban is a persistent job system built on Postgres and Ecto: jobs are rows, workers are modules, and queue state lives in the database you already operate. That design gives it properties most queues can't match, like enqueueing a job in the same transaction as your domain writes. [Oban Web](https://oban.pro/) adds a dashboard (free and open source since Oban 2.19), and Oban Pro layers on workflows, batching, and advanced features commercially.

### Oban key features

* Declarative retries. `use Oban.Worker, queue: :webhooks, max_attempts: 10` on a worker module. The default is 20 attempts with exponential backoff and jitter, customizable per worker with a `backoff/1` callback.
* Transactional enqueueing. `Ecto.Multi` lets a job insert commit or roll back with your own data, eliminating a whole class of "acked but never enqueued" bugs that plague Redis-backed queues.
* Unique jobs. `unique: [period: 60]` gives you deduplication at insert time, useful when providers deliver the same event twice.
* Explicit job states. `available`, `executing`, `retryable`, `discarded`, `cancelled`, with control flow via return values (`{:error, reason}`, `{:cancel, reason}`, `{:snooze, 60}`).
* Oban Web. Queue throughput, job states, and manual retry from a dashboard mounted in your Phoenix app.

### Why Oban gets used for webhook retries

Because the path of least resistance leads straight there. A webhook controller doing slow work inline starts timing out or dropping events, and every Elixir resource says the same thing: get the work out of the request. So you write this:

```
defmodule MyAppWeb.StripeWebhookController do
  use MyAppWeb, :controller

  def create(conn, _params) do
    payload = conn.assigns.raw_body
    signature = get_req_header(conn, "stripe-signature") |> List.first()

    with {:ok, event} <- Stripe.Webhook.construct_event(payload, signature, secret()),
         {:ok, _job} <- Oban.insert(ProcessStripeWebhook.new(%{event: event.id, payload: payload})) do
      send_resp(conn, 200, "ok")
    else
      _ -> send_resp(conn, 400, "invalid")
    end
  end
end

```

And a worker with a retry policy:

```
defmodule MyApp.Workers.ProcessStripeWebhook do
  use Oban.Worker, queue: :webhooks, max_attempts: 10

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"payload" => payload}}) do
    # process the event
    :ok
  end
end

```

This is a real improvement over processing inline, and Oban's transactional insert makes it more trustworthy than the equivalent Redis-backed pattern on other stacks. For one provider at modest volume, it's fine.

## Where Oban starts to hurt in a webhook context

The problems arrive with scale, with provider count, or with the first incident that makes you realize what you can't see.

The raw-body problem comes first. Signature verification needs the exact bytes the provider sent, and `Plug.Parsers` consumes the body before your controller runs. So before you've verified a single webhook, you're writing a custom `body_reader` to cache the raw body, wiring it into your endpoint, and making sure it only applies to webhook routes. Every Elixir team that has integrated Stripe knows this dance. It's solved, but it's the first sign that webhook ingestion is fighting the framework rather than using it.

Signature verification multiplies. `stripity_stripe` handles Stripe. Then you add Shopify, and you're computing `:crypto.mac(:hmac, :sha256, ...)` over the raw body and comparing with `Plug.Crypto.secure_compare/2`. Then GitHub, with a different header format. Each one is security-sensitive code with subtle failure modes (raw-body access, timing-safe comparison, timestamp tolerance), duplicated across controllers or half-abstracted into plugs you now maintain. See [how to implement SHA256 webhook signature verification](/webhooks/guides/how-to-implement-sha256-webhook-signature-verification) for what this involves per provider.

Postgres carries traffic you don't control. The BEAM will happily accept a webhook storm; Phoenix isn't the bottleneck. But every accepted webhook is a synchronous insert into `oban_jobs`, so a flash sale's worth of Shopify events becomes a write burst against the same database serving your application: connection pool pressure, WAL volume, index churn, and table bloat that the Pruner cleans up later. Queue concurrency limits (`queues: [webhooks: 10]`) protect your workers but push the backlog into the database, where it competes with your actual workload. Scaling ingestion means scaling Postgres.

An `oban_jobs` row is a poor event log. After the last attempt, the job lands in `discarded` with an errors array: one entry per attempt, each with a stack trace. What you want to know is: which Stripe event was this, what were its headers, what did the provider send, and what happened on each attempt? Headers were never stored unless you put them in `args`, payloads live inside a JSONB column you query by hand, and pruning eventually deletes the evidence. Answering "did we receive Shopify order 4521's webhook?" means writing SQL against job internals.

Retry policy lives in code. `max_attempts` and `backoff/1` are compile-time worker options. Giving Stripe a three-day window while capping a chatty analytics provider at an hour means separate worker modules or config plumbing, and any change is a deploy. During an incident ("their API is down, extend retries and pause delivery"), you can pause an Oban queue at runtime, which is more than most stacks get, but reshaping retry policy still means shipping code.

Oban Web can't see webhooks. The dashboard shows queues, states, and throughput: job health. It doesn't know what a webhook is. There's no per-source view, no full-text search across event payloads, and no alerting when a particular provider's events start failing (that's Pro territory or your own Telemetry handlers).

None of this makes Oban a bad tool; it may be the best-designed job queue in this series. Webhook ingestion simply has requirements (verification, spike isolation, per-source policy, event-level visibility) that sit upstream of even a very good general-purpose queue.

## Oban alternatives for webhook retries

### Hookdeck Event Gateway

[Hookdeck Event Gateway](/event-gateway) is managed webhook infrastructure that sits upstream of your Phoenix application. Providers send webhooks to a Hookdeck URL; Hookdeck verifies signatures, queues events durably, delivers them to your endpoint with configurable retries, and gives you event-level observability across the whole flow. Your controller receives pre-verified events and just processes them.

The architectural shift looks like this:

Before:

```mermaid
flowchart LR
    A[Stripe] --> B[Phoenix controller<br/> raw body + signature verification + retry policy in code]
    B --> C[oban_jobs table]
    C --> D[Oban worker]
    D --> E[Handler logic]

```

After:

```mermaid
flowchart LR
    A[Stripe] --> B[Hookdeck<br/> signature verification + retry policy as config]
    B --> C[Phoenix controller]
    C --> D[Handler logic]

```

#### Hookdeck Event Gateway key features

* 160+ pre-configured sources. Stripe, Shopify, GitHub, Twilio, Paddle, and a hundred more, with [signature verification](/docs/sources) built in. Adding a provider is configuration, not another raw-body plug and HMAC implementation.
* Durable ingestion, independent of your app. Hookdeck acknowledges the provider and queues the event before your application is involved. A spike is absorbed at the gateway with rate-limited delivery to your endpoint; Postgres never sees the write storm. If your app is down or deploying, events wait and deliver when it's back.
* Per-connection retry policies, no deploy. Set Stripe to retry for days and a noisy source to give up in an hour, from the dashboard, live. Pause and resume delivery per source during incidents. See the [retries documentation](/docs/retries).
* Issues and event-level observability. Every event with full headers, body, and per-attempt delivery history, plus full-text search across payloads. Nothing depends on what you remembered to store in `args`, and failed deliveries become [Issues](/docs/issues) with alerting.
* One-click replay. Replay any event, individually or in bulk, as the original HTTP request rather than a re-run of a discarded job.
* Local development with the CLI. `hookdeck listen 4000` forwards real webhooks to your local Phoenix server, with no ngrok tunnel, event history that survives restarts, and shared access for a whole team.

#### How does Hookdeck Event Gateway compare to Oban?

<table>
<tr>
<th><strong>Capability</strong></th>
<th><strong>Hookdeck Event Gateway</strong></th>
<th><strong>Oban</strong></th>
</tr>
<tr>
<td>Webhook awareness</td>
<td>✅ 160+ sources with built-in signature verification</td>
<td>❌ Hand-rolled per provider (<i>stripity_stripe</i> covers Stripe only)</td>
</tr>
<tr>
<td>Retry policy</td>
<td>✅ Per-connection, changed in the dashboard</td>
<td>❌ <i>max_attempts</i>/<i>backoff/1</i> in code, changed by deploy</td>
</tr>
<tr>
<td>Spike protection</td>
<td>✅ Absorbed at the gateway, rate-limited delivery</td>
<td>❌ Every webhook is a synchronous Postgres insert</td>
</tr>
<tr>
<td>Failed-event visibility</td>
<td>✅ Issues with full payload, headers, attempt history, search</td>
<td>ℹ️ <i>discarded</i> row: args JSONB + errors array, pruned over time</td>
</tr>
<tr>
<td>Replay</td>
<td>✅ One-click replay of the original event</td>
<td>ℹ️ Manual retry of the job row</td>
</tr>
<tr>
<td>Operations</td>
<td>✅ Managed</td>
<td>❌ Postgres sized for ingestion, Pruner, pool tuning: yours to run</td>
</tr>
<tr>
<td>Local development</td>
<td>✅ Via <i>hookdeck listen</i>, no tunnel, persistent history</td>
<td>ℹ️ ngrok plus a local database</td>
</tr>
<tr>
<td>Self-hosting</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Non-webhook jobs</td>
<td>❌</td>
<td>✅ Everything, with transactional enqueue</td>
</tr>
</table>
<br />

Pricing starts free, with paid plans from $39/month; see [hookdeck.com/pricing](/pricing). All paid tiers carry a 99.999% uptime SLA.

### Broadway with a message broker

[Broadway](https://elixir-broadway.org/) is Elixir's data-ingestion pipeline library, built on GenStage, with producers for SQS, Google Pub/Sub, RabbitMQ, and Kafka. For high-volume event streams it's excellent: back-pressure, batching, and concurrency control the BEAM way.

For webhooks, though, Broadway starts one step too late. Webhooks arrive as HTTP requests, so something still has to receive them, verify them, and publish them into the broker, and that something is your Phoenix app with all the code this article is about. You've added a broker to operate (or a cloud queue to pay for) and gained real throughput headroom, but the webhook-specific gaps (verification, per-source policy, event-level visibility, replay) are untouched. Broadway is the right tool when webhook volume becomes a streaming problem, and it pairs well with a gateway in front.

### Amazon SQS and cloud queues

Pointing your controller at SQS (consumed by Broadway or a simple poller) removes queue operations from Postgres entirely: AWS runs the queue and it scales without you. Google Cloud Tasks fills a similar role with an HTTP push model.

But a cloud queue is still a generic queue. You keep writing verification code, the enqueue still happens in your request path, dead-letter queues carry even less context than `oban_jobs`, and debugging now spans your app, the AWS console, and CloudWatch. You've traded Postgres write pressure for AWS plumbing, and given up Oban's transactional enqueue along the way. See [AWS SQS alternatives for webhooks](/webhooks/platforms/aws-sqs-alternatives-for-webhooks) for a closer look.

### Convoy

[Convoy](https://getconvoy.io/) is an open-source webhook gateway, a closer conceptual match than any general queue, with signature verification, retries, and a delivery dashboard. If self-hosting is a hard requirement (data residency, compliance, policy), it's the credible option in this list.

The trade-off is that self-hosting a webhook gateway means operating a webhook gateway: Postgres, Redis, and the service itself, sized for your spikes, monitored by you, and it's a Go service in an Elixir shop. Its source catalog and observability depth are also thinner than Hookdeck's. You'd be swapping "size Postgres for ingestion" for "operate a gateway": a better tool, but you're still going to get paged. For more, see the [Hookdeck Event Gateway vs Convoy comparison](/webhooks/platforms/hookdeck-event-gateway-vs-convoy-webhook-receiving-comparison).

## When to keep Oban

Keep it for almost everything. Domain jobs, emails, billing runs, scheduled work: Oban with transactional enqueueing is as strong a default as any ecosystem in this series has, and nothing here argues otherwise.

Keeping it for webhook retries specifically is reasonable when volume is low, providers number one or two, and the failure modes above are theoretical for you. A Phoenix app receiving a few hundred Stripe events a day does not need dedicated webhook infrastructure; `stripity_stripe`, a raw-body plug, and a worker will do fine. The calculus changes when webhooks become critical: multiple providers, revenue depending on events being processed, spikes correlated with your busiest traffic, or an on-call rotation that has already written SQL against `oban_jobs` at 2 a.m.

The most common end state isn't a migration away from Oban at all. It's a split: Hookdeck owns the webhook boundary, and Oban keeps doing everything else. Often the worker modules survive; they just start receiving pre-verified events.

## Migrating Phoenix webhook retries to Hookdeck Event Gateway

The migration is incremental and runs in parallel with your existing path:

1. Create a [source](/docs/sources) in Hookdeck for your provider (say, Stripe). You get a webhook URL with Stripe's signature verification already configured.
2. Create a connection from that source to your Phoenix endpoint, say `https://app.example.com/webhooks/stripe`, and set the retry policy per source in the dashboard.
3. Point the provider at Hookdeck. Update the webhook URL in Stripe's dashboard. Run both paths in parallel while you validate; Hookdeck's event log shows exactly what's flowing.
4. Simplify the controller. Verification has moved upstream, so the route now receives a verified event, and the raw-body plug can go. You can process inline or keep inserting an Oban job. Either way, retries for delivery failures now happen at the gateway, with full event context.
5. Keep Oban for everything else. Nothing about domain jobs, emails, or scheduled work changes.

For local development, run `hookdeck listen 4000` and real provider webhooks arrive at your local Phoenix server: the same source configuration from development through CI to production, no tunnel, and the event history is there when you restart. [Install the CLI](/docs/cli)

For the broader pattern, see [managed webhook gateway vs DIY queue-backed infrastructure](/webhooks/guides/managed-webhook-gateway-vs-diy-queue-backed-infrastructure).

## Hookdeck Event Gateway is the managed answer for webhook retries

Oban earned its place as Elixir's default answer to background work, and it should keep that job. But webhook ingestion asks questions a general-purpose queue can't answer: who verifies the signature, what absorbs the spike, where does the failed event live, and how fast can you change a retry policy when a provider has a bad day.

[Hookdeck Event Gateway](/event-gateway) answers those upstream of your application. Verification, durable queueing, per-source retries, event-level observability, and replay come managed, with your Phoenix app receiving clean, verified events. Your controllers get simpler. Oban stays for what it's actually good at.