Skip to main content

Webhook Event Log and Replay

The gateway persists outbound webhook events and their delivery attempts so operators and integrators can inspect failures, replay events, and manage retention without digging directly through service logs.

This page describes the persisted webhook-event surface, the difference between event replay and DLQ retry, and the SDK helpers exposed from the normal admin clients.

Refund and void durability

Processing persists the canonical payment.refunded or payment.voided event inside the same database transaction as the final financial projection. An event-persistence failure aborts that local finalization and retains the original operation for reconciliation; it must not cause a second processor refund or void. Unresolved outcomes do not create success events.

The event and a permanent, Gateway-owned per-operation notification marker are committed together. Once marked, an operation never creates another canonical event, even after the original event row expires. The marker does not alter financial evidence or get copied from a parent payment to a new refund.

Unmarked legacy refunds and voids never automatically create a new event. Recovery can verify an existing canonical event's ownership and retain the permanent marker without emitting it again. If no event exists, every replay logs approval required; historical gap repair requires an explicitly approved, ownership- and evidence-bounded backfill, regardless of age.

There is no timestamp-window heuristic. A newly admitted retry can cache a row's recent bookkeeping updated_at in a response, and late WAL closure is not a financial approval time. Neither can turn a legacy operation into a new event. Existing financial responses and reversal timing remain intact, including legacy voids with unknown compensation evidence. Event replay is not a substitute for creating a missing canonical event, and a VOIDED status alone does not establish full reversal or compensation evidence.

Return-converted (A0014) refund replays recover the parent void, not a refund success. A retained canonical void event may reference the parent or its verified return-converted child. Recovery preserves the original requested-operation outcome and never invents an amount or issues a provider inquiry.

Persisted fanout recovery delivers committed event rows independently of the request; it cannot discover a historical event that was never inserted.

Replay vs. DLQ retry

These two actions are related but not interchangeable.

Replay an event

Use event replay when you want to enqueue delivery again from the persisted event record.

  • Endpoint: POST /api/v1/webhook-events/{eventId}/replay
  • Typical use: replay the event to every active subscription, or to a filtered subset of subscriptions
  • Result: new delivery attempt rows are queued for that event

Retry a DLQ delivery

Use DLQ retry when a specific delivery row already failed all automatic attempts and was marked DLQ_PENDING or DLQ_EXHAUSTED.

  • Endpoint: POST /api/v1/webhook-deliveries/{deliveryId}/retry
  • Typical use: the merchant fixed their endpoint and you want to re-drive the exact failed delivery row
  • Result: the gateway resets that delivery row back into the delivery worker

Rule of thumb:

  • if you are operating from an event-centric view, use replay
  • if you are operating from a delivery-row / DLQ view, use retry

SDK helpers

The persisted-event surface is exposed from the normal admin clients in both SDKs.

TypeScript

import {
  GatewayAdminClient,
  getWebhookEventDetails,
  replayFailedWebhookEventDeliveries,
  replayWebhookEventDeliveries,
} from '@peakgateway/sdk'

const client = new GatewayAdminClient({
  environment: 'staging',
  clientId: process.env.GATEWAY_CLIENT_ID,
  clientSecret: process.env.GATEWAY_CLIENT_SECRET,
})

const details = await getWebhookEventDetails(client.webhookEvents, 'evt_123')

// Replay only failed / DLQ deliveries for the event.
await replayFailedWebhookEventDeliveries(client.webhookEvents, details.event.id)

// Or replay a specific subset of prior delivery rows.
await replayWebhookEventDeliveries(client.webhookEvents, details.event.id, {
  deliveryIds: details.deliveries.filter((delivery) => delivery.status === 'FAILED').map((delivery) => delivery.id),
})

// Retry one DLQ delivery row directly.
await client.webhookEvents.retryDeliveryFromDlq('del_456')

Kotlin

import com.myriad.gateway.GatewayAdminClient
import com.myriad.gateway.GatewayConfig
import com.myriad.gateway.webhookevents.findRetentionConfigForOrganization
import com.myriad.gateway.webhookevents.getDetails
import com.myriad.gateway.webhookevents.replayDeliveries
import com.myriad.gateway.webhookevents.replayFailedDeliveries

suspend fun operateWebhooks() {
    val client = GatewayAdminClient(GatewayConfig("client-id", "client-secret"))

    val details = client.webhookEvents.getDetails("evt_123")

    client.webhookEvents.replayFailedDeliveries(details.event.id)

    client.webhookEvents.replayDeliveries(
        eventId = details.event.id,
        deliveryIds =
            details.deliveries
                .filter { it.status == "FAILED" }
                .map { it.id }
                .toSet(),
    )

    client.webhookEvents.retryDeliveryFromDlq("del_456")

    val retention = client.webhookEvents.findRetentionConfigForOrganization("org_123")
    client.close()
}

Delivery diagnostics

Persisted delivery rows can include:

  • delivery status
  • HTTP status from the destination endpoint
  • attempt count
  • last error message
  • allowlisted response headers (lastResponseHeaders)
  • replay lineage (replayOfDeliveryId)

That data is the first place to look before jumping to raw service logs.

Correlation IDs inside received webhook handlers

If your receiver wants a durable way to map a received webhook back to gateway traces or support investigations, use the verifier helpers that preserve the delivery correlation id.

  • TypeScript: WebhookVerifier.verifyDelivery(...)
  • Kotlin: WebhookVerifier.verifyDelivery(...)

Those helpers return the decoded event plus a correlationId extracted from traceparent, X-Cloud-Trace-Context, or X-Request-Id headers when present.

Boundaries

Use only the authorized organization's event surface. Event delivery records are not a security audit trail. Receiver processing must tolerate duplicate delivery; replay does not repeat the original payment. An application delivery DLQ is not a PubSub queue and retrying a delivery does not drain infrastructure backlog.

For merchant-status store.authorized and store.revoked events, lifecycleVersion is a monotonically increasing integer scoped to organizationId and locationId. Both billing and manual status changes use the same counter. Persist the highest version processed for that location atomically with the receiver's state change; ignore a lower or equal version, while retaining event-ID deduplication. Versions may skip values (some status transitions do not emit an event). Versioned merchant-status deliveries include organizationId; the SDK exposes it as optional because older OAuth store notifications can omit it. Do not treat an absent version or organization as newer than a versioned merchant-status event.

The gateway transaction atomically fences child creation and dispatch admission, and a superseding transition cancels locally queued deliveries. An HTTP request admitted before the transition can still arrive or finish after the new event; network timeouts do not prove the receiver did not apply it. Subscription fanout and receiver processing are not ordered. The receiver's version check is needed to prevent a late old revoke from undoing a newer authorization.

For the human workflow see Support event log. See also Gift-card events and Device events.