Documentation/titan/Webhook receiver
In this section

Webhook receiver

Receiving webhooks from a third party (Stripe, GitHub, payment processor, etc.) seems trivial — but it has subtle requirements that go wrong in production: signatures, replay protection, idempotency, and isolating slow downstream work from the acknowledgement window.

Shape

  • Signature verification. Reject anything not signed by the expected sender — usually HMAC with a shared secret.

  • Idempotency. Webhook senders retry on timeout. Process each event ID at most once.

  • Fast 2xx response. Senders treat slow ack as failure and retry. Acknowledge quickly; process asynchronously.

  • Dead-letter on failure. Failures persist somewhere for inspection / manual retry — not lost.

Architecture

flowchart LR
  Sender[Webhook sender]
  Sender -->|POST /webhooks/...| Edge[HTTP handler]
  Edge --> Verify{Signature valid?}
  Verify -- no --> R401[401]
  Verify -- yes --> Dedup{Event ID seen?}
  Dedup -- yes --> R200a[200 — already processed]
  Dedup -- no --> Mark[Mark seen<br/>Redis SETEX]
  Mark --> Enqueue[Emit event for worker]
  Enqueue --> R200b[200]
  Enqueue --> Q[(Queue / event bus)]
  Q --> Worker[Worker]
  Worker --> Handler[Domain handler]
  Worker -- failure --> DLQ[(DLQ)]

Setup

import { Module } from '@omnitron-dev/titan';
import { ConfigModule } from '@omnitron-dev/titan/module/config';
import { LoggerModule } from '@omnitron-dev/titan/module/logger';

import { TitanRedisModule } from '@omnitron-dev/titan-redis';
import { TitanRateLimitModule } from '@omnitron-dev/titan-ratelimit';
import { EventsModule } from '@omnitron-dev/titan-events';
import { TitanMetricsModule } from '@omnitron-dev/titan-metrics';

@Module({
  imports: [
    ConfigModule.forRoot({/* … */}),
    LoggerModule.forRoot({ level: 'info' }),
    TitanRedisModule.forRoot({ config: { url: env.REDIS_URL } }),
    TitanRateLimitModule.forRoot({
      enabled:         true,
      storageType:     'redis',
      strategy:        'sliding-window',
      defaultLimit:    500,
      defaultWindowMs: 60_000,
    }),
    EventsModule.forRoot({ wildcard: true, maxListeners: 50 }),
    TitanMetricsModule.forRoot({ appName: 'webhooks' }),
    WebhooksModule,                          // your handlers
  ],
})
export class AppModule {}

Handler — verify, deduplicate, enqueue

import { Service, Inject } from '@omnitron-dev/titan';
import { Public } from '@omnitron-dev/titan/netron';
import { Errors } from '@omnitron-dev/titan/errors';
import { InjectRedis, type IRedisClient } from '@omnitron-dev/titan-redis';
import { RateLimit, RATE_LIMIT_SERVICE_TOKEN, type IRateLimitService } from '@omnitron-dev/titan-ratelimit';
import { EVENTS_SERVICE_TOKEN, type EventsService }
  from '@omnitron-dev/titan-events';
import { LOGGER_SERVICE_TOKEN, type ILoggerModule } from '@omnitron-dev/titan/module/logger';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SIGNING_SECRET = process.env.WEBHOOK_SIGNING_SECRET!;
const DEDUP_TTL_S    = 7 * 24 * 60 * 60;     // 7 days

@Service({ name: 'webhooks' })
class WebhooksService {
  // ILoggerModule exposes create(); the child ILogger is what has
  // .info/.warn/.error.
  //
  // A GETTER, not a field initializer. `private readonly logger =
  // this.loggerModule.create(…)` looks equivalent and crashes: field
  // initializers run BEFORE constructor parameter properties are assigned, so
  // `this.loggerModule` is still undefined and construction throws
  // "Cannot read properties of undefined". A getter defers the read to first
  // use, by which time the constructor has run.
  private get logger() {
    return this.loggerModule.create('webhooks');
  }

  constructor(
    @InjectRedis() private readonly redis: IRedisClient,
    @Inject(EVENTS_SERVICE_TOKEN) private readonly events: EventsService,
    @Inject(LOGGER_SERVICE_TOKEN) private readonly loggerModule: ILoggerModule,
    // @RateLimit reads the limiter off this exact property name.
    @Inject(RATE_LIMIT_SERVICE_TOKEN) private readonly __rateLimitService__: IRateLimitService,
  ) {}

  @Public()
  @RateLimit({ limit: 1_000, windowMs: 60_000 })   // options object only; no string key
  async receive(
    body:      string,                       // raw body for signature verification
    signature: string,
    eventId:   string,
    eventType: string,
  ) {
    // 1. Verify signature — constant-time comparison
    const expected = createHmac('sha256', SIGNING_SECRET).update(body).digest('hex');
    const ok = signature.length === expected.length &&
               timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!ok) throw Errors.unauthorized('invalid webhook signature');

    // 2. Deduplicate — SETEX returns OK on first set, nil on existing key
    const key = `webhook:seen:${eventId}`;
    const wasSet = await this.redis.set(key, '1', 'EX', DEDUP_TTL_S, 'NX');
    if (wasSet === null) {
      this.logger.info('webhook duplicate', { eventId, eventType });
      return { status: 'duplicate' };          // 200 — already processed
    }

    // 3. Enqueue for async processing — acknowledge fast
    await this.events.emitAsync(`webhook.${eventType}`, { eventId, body: JSON.parse(body) });

    return { status: 'accepted', eventId };
  }
}

Always verify on the RAW body, before any JSON parsing. JSON serialisation isn't byte-stable; even round-tripping JSON.parse → JSON.stringify can change a single byte and break the HMAC.

Handler — async processing

import { Injectable } from '@omnitron-dev/titan';
import { OnEvent } from '@omnitron-dev/titan-events';
import { Retry } from '@omnitron-dev/titan/decorators';

@Injectable()
class StripePaymentHandler {
  @OnEvent({ event: 'webhook.charge.succeeded', async: true })
  @Retry({ attempts: 3, delay: 500 })
  async onChargeSucceeded(payload: { eventId: string; body: any }) {
    try {
      await this.applyPayment(payload.body);
    } catch (e) {
      // Failure persisted to DLQ by the metrics + logger combo
      this.logger.error('payment processing failed', { eventId: payload.eventId, error: e });
      throw e;
    }
  }
}

Cross-module wiring notes

ConcernWiring detail
Signature on raw bodyRead the raw request body before JSON parsing — @Validate(Schema) runs after deserialise
Deduplication key TTLMatch or exceed the sender's max retry window (e.g. Stripe = 3 days, GitHub = 8 hours)
Rate limit per senderUse a key that includes the sender ID — otherwise one sender saturates the bucket for all of them
Idempotency on writesDownstream handlers should also be idempotent — webhooks at-least-once even with dedup
Event bus vs queueIn-process EventsService is sufficient for single-pod; for multi-pod use titan-notifications over Rotif
DLQIf using titan-notifications worker mode, failed deliveries auto-route to the Rotif DLQ
Metric per event-typemetrics.recordTyped('counter', 'webhook.received.total', { type: eventType, status }, 1)

Production checklist

  • Raw body captured before JSON parse — required for HMAC

  • Constant-time signature comparison (timingSafeEqual) — never === on secrets

  • Dedup TTL ≥ sender's max retry window

  • Dedup uses SET NX EX — atomic check-and-set

  • Fast ack — return 2xx within sender's timeout (typically 5-10 s)

  • Async processing via event bus or queue — never block the ack

  • Handler @Retry for transient failures

  • DLQ for non-retryable failures — inspectable, replayable

  • Rate limit per-sender if you receive from many sources

  • Metrics on every step: received, deduplicated, accepted, processed, failed

  • Replay test in staging — re-send the same event, assert it's deduplicated

See also