Skip to main content

Shipment Webhooks Without Duplicate ERP Updates: An Inbox and Outbox Design

Updated 6 min readMarkdown

A shipment integration receives a departure event, updates an ERP record, and loses the HTTP response. The provider retries. A few minutes later, an earlier loading event arrives. A handler that simply overwrites shipment.status can now create duplicate work and move the shipment backwards.

These are separate problems: delivery duplication and business-event ordering. Solve them explicitly instead of hoping that callbacks arrive once, in sequence.

The design below is a hypothetical implementation. All identifiers, timestamps and shipment events are synthetic; they do not describe a Zenith Eclipse system, customer or shipment.

Establish the provider contract first

Record how each provider identifies events, authenticates requests, retries failures, represents corrections and distinguishes actual events from estimates. Do not assume these behaviours are universal.

For comparison, Stripe documents duplicate deliveries and delivery without ordering guarantees. That is useful evidence of webhook failure modes, not a specification for a shipping API. A carrier's own contract must determine its adapter behaviour.

If the provider supplies no stable event identifier, agree on a documented reconciliation strategy. Hashing an entire payload can identify identical bytes, but it does not establish whether two different messages describe the same business occurrence.

Give the inbox a durable identity

Use an inbox table to record accepted events before returning success. Scope identity to the authenticated integration and tenant, not merely a request-body field that a sender can choose.

CloudEvents 1.0.2 defines uniqueness through the combination of source and id. It is a useful envelope model, but adopting that envelope does not make a provider's events trustworthy or ordered.

Here is a minimal PostgreSQL storage sketch:

CREATE TABLE shipment_inbox (
  tenant_id text NOT NULL,
  source text NOT NULL,
  event_id text NOT NULL,
  shipment_ref text NOT NULL,
  event_time timestamptz,
  received_at timestamptz NOT NULL DEFAULT now(),
  semantic_hash text NOT NULL,
  normalized_event jsonb NOT NULL,
  processing_state text NOT NULL DEFAULT 'pending'
    CHECK (processing_state IN ('pending', 'processed', 'quarantined')),
  PRIMARY KEY (tenant_id, source, event_id)
);

The composite primary key is the concurrency guard. A preliminary SELECT followed by an unprotected INSERT is insufficient when two workers receive the same event together.

Insert using bound parameters:

INSERT INTO shipment_inbox
  (tenant_id, source, event_id, shipment_ref,
   event_time, semantic_hash, normalized_event)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (tenant_id, source, event_id) DO NOTHING
RETURNING event_id;

PostgreSQL's RETURNING behaviour lets the caller distinguish a newly inserted row from a skipped duplicate. When insertion returns no row, read the existing record in a subsequent statement and compare the immutable event meaning. Account for the chosen transaction isolation and retry serialization failures where applicable.

The hash should use a documented, versioned normalization of immutable fields. Exclude delivery-attempt metadata. A reused identity with materially different data is a conflict to quarantine and investigate, not permission to overwrite the first event. If the provider explicitly supports revisions, model its revision rules instead.

Commit intake before acknowledging it

The HTTP boundary should authenticate the request, validate size and schema, establish tenant ownership, and durably record either accepted work or an actionable quarantine record. Return success only after that record commits, following the provider's acknowledgement contract.

If authentication uses signatures over raw bytes, preserve those bytes until verification finishes. Stripe, for example, specifically requires the raw request body. Use each provider's documented verification method rather than transplanting another platform's signature scheme.

Keep credentials outside payloads and logs. Store only needed fields, restrict access, and define retention for shipment references and supporting documents. A digest is not an access-control mechanism.

Separate the event history from the current view

Consider this synthetic arrival sequence:

Event Occurred at, UTC Received at, UTC
E18: actual departure 08:10 08:12
E17: actual loading 08:00 08:20

Keep both facts. The later receipt of E17 should not automatically replace the departure view.

Store event time and receipt time separately. A timestamp alone is not a reliable sequence: precision, clock quality and provider semantics differ. Prefer a provider revision or sequence when its scope and guarantees are documented. Otherwise, calculate the view through explicit business rules and reconcile uncertain cases with the authoritative provider record.

Avoid assigning every status a universal numeric rank. A correction, cancellation or event on another transport leg may legitimately change the interpretation. Model actual versus estimated events, leg identity and corrections separately.

For operational context, Zenith Eclipse's ocean freight scope identifies booking, cutoff and document dependencies. Its sea–rail–road transport scope adds connections and equipment handoffs between modes. A hypothetical event model should keep the leg and responsible source with each milestone; one leg's departure cannot stand in for completion of the entire movement. These service descriptions are not API contracts.

Process once locally; deliver safely downstream

A worker can apply this transactional outline:

begin transaction
  lock one pending inbox row
  lock the affected tenant-scoped shipment row
  validate the event against existing facts
  append the fact and update the derived view
  insert an outbox message with a stable message ID
  mark the inbox row processed
commit

Use deterministic lock ordering and retry transient transaction failures. Unknown shipment references require a defined quarantine or deferred-resolution path. Database transactions make the local changes succeed or fail together; they do not include a remote ERP HTTP request.

A separate relay sends committed outbox messages. The transactional outbox pattern avoids losing the downstream notification between a database commit and a send attempt. The relay can still send twice if it crashes after delivery but before recording success. Reuse the stored message ID on retries, and require a supported idempotency key or deduplication at the consumer.

Test the boundaries, not just the happy path

Before deployment, inject simultaneous duplicates, reused IDs with changed content, late events, unknown shipments, and crashes immediately before and after each commit. Simulate a successful ERP response that never reaches the relay. Confirm that replay preserves facts without repeating external effects.

This design offers controlled retries and auditable state transitions. It does not promise end-to-end exactly-once delivery. The practical goal is narrower and testable: an accepted event remains recoverable, duplicates do not repeat committed local work, and uncertain downstream outcomes are reconciled before another action is attempted.


About the publisher: Zenith Eclipse Co works across agricultural commodities, international logistics and machinery sourcing.

No comments yet. Be the first to comment.

More from this blog