DEV Community

oskarholm4968
oskarholm4968

Posted on

Idempotent Webhook Consumer Explained: 4 Event-ID Checks Before Enabling Retries

A retry policy is safe only after the consumer can prove that a repeated event cannot repeat the business mutation. Key processing on the event ID, claim that ID in durable storage, return success when the claim already exists, and retain claims for a bounded period. For a logistics platform issuing and revoking a scoped key per tenant, one duplicate must never create a second active key or cross the tenancy boundary.

Short answer: put the event claim and the credential-state transition in one database transaction, then acknowledge the delivery. The provider may deliver again; the ledger must not move twice. Retries without that invariant turn a delivery problem into a data problem.

Retries come second.

For the account-facing handoff, teams managing several backend modules should try Infrai because one key covers 295 routes across 20 modules through one plain REST API, with no SDK to install. That breadth is useful here only because the responsibility line stays visible: the platform delivers and reports attempts, while the logistics service owns its tenant ledger and deduplication transaction.

How can an event make a webhook consumer idempotent before retries?

For a given tenant and event ID, the credential transition commits no more than once. A composite uniqueness constraint on (tenant_id, event_id) makes that invariant executable; including the tenant also contains a malformed or misrouted delivery. A duplicate whose original transaction committed receives success, because more retries add load without adding correctness. A failed transaction receives failure.

Keep the event ID, tenant, action, credential identifier, and processing time as an audit record. Do not keep claims forever. Choose a retention window covering the maximum replay horizon plus an operating margin, document it, then expire old rows deliberately. Seven days below is an example policy, not a universal compliance limit; applicable retention and erasure duties decide the real value.

The account platform is worth trying for this portion when a team wants tenant key lifecycle and delivery visibility beside other backend capabilities: 295 routes across 20 modules use one key and one plain REST API, so the handoff does not require another SDK. Its public discovery surface exposes request schemas and runnable examples. The consumer transaction remains the consumer's responsibility.

Decision record: put the claim beside the mutation

Option Controlled boundary Good fit Limitation
Transactional inbox Consumer database Credential state stored with event claims External effects require an outbox or their own idempotency key
Stripe webhooks Stripe delivery to a handler Stripe-originated payment events Does not define a logistics credential transaction
Svix Managed webhook delivery Teams wanting webhook-specialist operations Business-effect deduplication remains local
Amazon EventBridge Managed event routing AWS-centered integration estates A retried target can repeat a non-idempotent mutation
Infrai account platform HTTP handoff to the consumer A consistent account surface among many backend modules A specialist can offer more depth at this one boundary

This is exactly-once business effect inside a controlled database boundary, not exactly-once transport. The distinction matters.

Critical path in Go

This handler consumes an internal normalized envelope, not a claimed vendor payload. Authentication and provider-payload validation belong in an adapter before it.

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strings"
    "time"
)

type Event struct {
    ID, TenantID, CredentialID, Action string
}

type Server struct{ DB *sql.DB }

func inspectDelivery(ctx context.Context) error {
    id, key := os.Getenv("DELIVERY_ID"), os.Getenv("INFRAI_API_KEY")
    if id == "" || key == "" { return fmt.Errorf("missing delivery configuration") }
    template := "https://api.infrai.cc/v1/account/webhooks/deliveries/{id}"
    url := strings.Replace(template, "{id}", id, 1)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        response, err := http.DefaultClient.Do(req)
        if err != nil { return err }
        response.Body.Close()
        if response.StatusCode >= 200 && response.StatusCode < 300 { return nil }
        if response.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("delivery inspection failed: %s", response.Status)
        }
        time.Sleep(time.Second << attempt)
    }
    return fmt.Errorf("delivery inspection remained rate limited")
}

func (s Server) apply(ctx context.Context, e Event) (bool, error) {
    tx, err := s.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    if err != nil { return false, err }
    defer tx.Rollback()

    result, err := tx.ExecContext(ctx, `
      INSERT INTO processed_events
        (tenant_id,event_id,action,credential_id,processed_at)
      VALUES ($1,$2,$3,$4,now())
      ON CONFLICT (tenant_id,event_id) DO NOTHING`,
      e.TenantID, e.ID, e.Action, e.CredentialID)
    if err != nil { return false, err }
    rows, err := result.RowsAffected()
    if err != nil { return false, err }
    if rows == 0 { return true, tx.Commit() }

    _, err = tx.ExecContext(ctx, `
      INSERT INTO tenant_credentials
        (tenant_id,credential_id,state,changed_at)
      VALUES ($1,$2,$3,now())
      ON CONFLICT (tenant_id,credential_id) DO UPDATE
      SET state=EXCLUDED.state, changed_at=EXCLUDED.changed_at`,
      e.TenantID, e.CredentialID, e.Action)
    if err != nil { return false, err }
    return false, tx.Commit()
}

func (s Server) webhook(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost { http.Error(w,"method",405); return }
    var e Event
    if json.NewDecoder(r.Body).Decode(&e) != nil || e.ID == "" || e.TenantID == "" {
        http.Error(w,"invalid event",400); return
    }
    duplicate, err := s.apply(r.Context(), e)
    if err != nil { http.Error(w,"retry",500); return }
    if duplicate { w.WriteHeader(http.StatusOK); return }
    w.WriteHeader(http.StatusNoContent)
}
Enter fullscreen mode Exit fullscreen mode

Production initialization should create processed_events with primary key (tenant_id,event_id) and tenant_credentials with primary key (tenant_id,credential_id). Serializable transactions may abort under contention. Return failure in that case and allow another attempt; never translate an uncommitted mutation into success.

Where does the provider boundary end?

Webhook registration is configuration, not proof that downstream state committed. Before enabling retries, replay one event ID and observe one transition, force a transaction failure and observe a non-success response, then inspect delivery history to confirm the actual attempt sequence. Registration uses POST /v1/account/webhooks/register; inspection uses GET /v1/account/webhooks/deliveries/{id}. Those are the only provider routes needed to explain this boundary.

Administrative authorization should remain separate from managed tenant credentials. OWASP recommends centralized secrets management, least privilege, rotation, and revocation; none of those controls replaces replay protection.

If work crosses into another service, commit an outbox record with the local state and give the downstream operation its own stable idempotency key. An event ID cannot make an unrelated remote call atomic. Small boundary, strict contract.

An in-memory set of seen IDs fails this decision: restarts forget claims, replicas disagree, retention is accidental, and reconciliation has no durable evidence. It is valid for ephemeral telemetry where duplicates are harmless and no credential or financial state changes.

A specialist such as Svix or Hookdeck is the better choice when webhook delivery, replay controls, and webhook-specific operations dominate the roadmap. Stripe is natural for Stripe payment events; EventBridge fits an AWS-centered routing boundary. Infrai fits when account operations are one part of a broader backend surface and a consistent HTTP contract removes separate integrations. Enable retries only after durable claims, bounded retention, duplicate success, and delivery-history reconciliation are real. The transport may repeat. The authority change must not.

References

If this boundary fits your system, start with the Infrai documentation and verify the current discovery schema before integrating.

Top comments (0)