Skip to content

Outbox Relay (Cron) ​

AuthHero delivers audit events and webhook dispatches through a transactional outbox. Most events are delivered per-request by the inline dispatcher, but transient failures (a webhook 5xx, a dropped connection) leave events in the outbox. A scheduled handler sweeps those up.

When you need this ​

You need a scheduled handler if any of the following is true:

  • You enabled the outbox (outbox: { enabled: true } on init())
  • You deliver hook.* webhooks and want at-least-once delivery under transient failure
  • You want processed events cleaned up on a retention schedule

If outbox is disabled, webhook delivery is fire-and-forget per request with no retry — no cron is possible or needed.

This cron is about delivery, not retention

runOutboxRelay cleans up outbox_events as a convenience, but it is not your retention story — it sweeps no other table, and it returns early when outbox is disabled. Schedule runRetention as well; it covers codes, sessions and outbox events in one call. Running both is safe.

One-call handler: runOutboxRelay ​

runOutboxRelay is the entire body of a scheduled handler. It builds the same destinations the inline dispatcher uses, mints per-tenant auth-service tokens via the same in-process path, drains the outbox, and cleans up processed events past the retention window.

ts
import { runOutboxRelay } from "authhero";

export default {
  async scheduled(_event: ScheduledEvent, env: Env) {
    await runOutboxRelay({
      dataAdapter,
      issuer: env.ISSUER,
      webhookInvoker, // same function passed to init()
      codeExecutor, // same executor passed to init() — see below
      retentionDays: 7,
    });
  },
};

Configuration ​

FieldRequiredDescription
dataAdapteryesSame DataAdapters you pass to init(). Must include outbox — the call is a no-op if it doesn't.
issueryesIssuer URL used when minting auth-service tokens. Typically env.ISSUER. Webhook receivers that validate iss will accept tokens from both the inline and cron paths.
webhookInvokernoSame shape as the webhookInvoker option on init(). Pass the same function — see below.
codeExecutornoSame executor passed to init({ codeExecutor }). Pass it so cron-drained hook.* events also run post-user-registration / post-user-deletion code hooks — otherwise a code hook that failed per-request delivery is silently skipped on retry.
retentionDaysnoDays to keep processed events before cleanup. Default 7.
batchSizenoMax events per drain pass. Forwarded to drainOutbox.
maxRetriesnoMax delivery attempts before an event is dead-lettered. Forwarded to drainOutbox.
webhookTimeoutMsnoHTTP timeout when the default invoker is used. Default 10000.
metricsnoSink for relay metrics. Same shape as init({ outbox: { metrics } }) — see Metrics.

Metrics ​

The relay can report structured counters instead of only logging. Pass a metrics callback and it is invoked for each of:

MetricTypeEmitted when
outbox_events_processed_totalcounter (value: 1)Every accepting destination delivered the event.
outbox_events_dead_lettered_totalcounter (value: 1)Retries were exhausted, or no destination accepted the event.
outbox_retry_delay_secondsobservationA destination failed; value is the backoff before the next attempt.

Each metric also carries tenantId, eventType, retryCount and source ("request" for the inline per-request relay, "cron" for runOutboxRelay), plus destination and error on failures. The sink is called synchronously and any error it throws is caught and logged, so a broken sink can never fail event delivery.

Pass the same sink to init() and runOutboxRelay() so both paths report together:

ts
import { createAnalyticsEngineOutboxMetricsSink } from "@authhero/cloudflare-adapter";

const metrics = createAnalyticsEngineOutboxMetricsSink({
  analyticsEngineBinding: env.OUTBOX_METRICS,
});

init({ dataAdapter, outbox: { enabled: true, metrics } });
await runOutboxRelay({ dataAdapter, issuer: env.ISSUER, metrics });

Any callback works — the core package never knows where the numbers go, so StatsD, OpenTelemetry or a plain console.log are equally valid sinks.

Pass the same webhookInvoker you pass to init() ​

If you provide a custom webhookInvoker to init() — e.g. to strip sensitive fields, add a signing header, or route to a non-HTTP transport — you must pass the same function to runOutboxRelay. Otherwise cron-drained deliveries will silently skip your invoker and fall back to a plain Authorization: Bearer <service-token> POST, diverging from the inline path.

ts
// Shared module — imported by init() and the scheduled handler
export const webhookInvoker: WebhookInvoker = async ({
  hook,
  data,
  tenant_id,
  createServiceToken,
}) => {
  const token = await createServiceToken();
  return fetch(hook.url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "X-Tenant-Id": tenant_id,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(redactInternal(data)),
  });
};

Cloudflare Workers example ​

Wire a cron trigger in wrangler.toml:

toml
[triggers]
crons = ["*/5 * * * *"]  # Every 5 minutes

Then in your worker entry:

ts
import { initMultiTenant } from "@authhero/multi-tenancy";
import { runOutboxRelay } from "authhero";
import { webhookInvoker } from "./webhook-invoker";

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const dataAdapter = createCloudflareD1Adapter(env.AUTH_DB);
    const { app } = initMultiTenant({
      dataAdapter,
      webhookInvoker,
      outbox: { enabled: true },
    });
    return app.fetch(request, env, ctx);
  },

  async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    const dataAdapter = createCloudflareD1Adapter(env.AUTH_DB);
    ctx.waitUntil(
      runOutboxRelay({
        dataAdapter,
        issuer: env.ISSUER,
        webhookInvoker,
        retentionDays: 7,
      }),
    );
  },
};

Picking a cron interval ​

  • Every 5 minutes is a reasonable default. Inline delivery handles the happy path; the cron only picks up events that failed a retry.
  • Shorter intervals reduce time-to-delivery after a webhook receiver recovers from an outage, at the cost of more idle invocations.
  • The relay claims events with a short lease, so overlapping invocations are safe — two workers running concurrently won't double-deliver.

Node / Docker / other platforms ​

Any scheduler can drive the relay. Call runOutboxRelay on a timer:

ts
import { runOutboxRelay } from "authhero";

setInterval(
  () => {
    runOutboxRelay({
      dataAdapter,
      issuer: process.env.ISSUER!,
      webhookInvoker,
      retentionDays: 7,
    }).catch((err) => console.error("outbox relay failed", err));
  },
  5 * 60 * 1000,
);

For Kubernetes / systemd, wire the same call to a cron-style job that runs every 5 minutes and exits.

Lower-level escape hatches ​

If runOutboxRelay doesn't fit your shape, the pieces it composes are individually exported:

  • drainOutbox(outbox, destinations, options?) — processes one batch of unprocessed events.
  • cleanupOutbox(outbox, { retentionDays }) — deletes processed events past the retention window.
  • createDefaultDestinations({ dataAdapter, getServiceToken?, webhookInvoker?, webhookTimeoutMs? }) — builds the [LogsDestination, WebhookDestination, RegistrationFinalizerDestination] array the inline dispatcher uses. Pass webhookInvoker here too for parity with the inline path.
  • LogsDestination, WebhookDestination, RegistrationFinalizerDestination, and the EventDestination interface — for fully custom destination arrays.

What happens to stuck events ​

Events that exceed maxRetries (default 5) are moved to a dead-letter state. They stop blocking the queue and are visible through the management API. See Failed Events for inspection and replay.

Dual-licensed: AGPL-3.0-only or commercial license.