DocsGuides

Guides

Build a reliable event delivery pipeline

4 min readReviewed July 2026

A production analytics call should not make a checkout fail, and a temporary network problem should not silently erase the event. The reliable pattern separates the business transaction from delivery, preserves an application-level event ID, and treats retries as normal.

GraphJSON accepts events over HTTP. The application still owns the path from “the action happened” to “the event received a successful response.”

Start with explicit delivery requirements#

Not every event deserves the same machinery.

Event Acceptable behavior
Debug breadcrumb Best effort may be enough
Feature usage Retry briefly; occasional loss may be tolerable
Subscription change Queue, retry, deduplicate, and reconcile
Billable usage Keep the transactional system as the source of truth and reconcile

Analytics should not become the canonical ledger for money, permissions, inventory, or another business-critical state. It can analyze those facts after the source system records them.

Use a stable event envelope#

Give every important event an identifier before the first delivery attempt:

{
  "event": "invoice_paid",
  "event_id": "evt_01J2Y9DA7W",
  "occurred_at": "2026-07-26T16:00:00Z",
  "account_id": "acct_7",
  "invoice_id": "in_91",
  "amount": 4900,
  "currency": "usd",
  "schema_version": 1
}

Send occurred_at as the GraphJSON timestamp in whole Unix seconds. Keep event_id inside the JSON payload so duplicates can be found later.

Generate the ID at the source of the event, not inside the delivery worker. A retry must keep the same ID.

Prefer an outbox or durable queue#

The strongest pattern writes the application change and an outgoing event in the same database transaction:

request
  ├─ update application state
  └─ insert outbox row
          ↓
      background worker
          ↓
      GraphJSON

If your stack cannot use a transactional outbox, enqueue after the business transaction commits. Decide what happens when the enqueue itself fails and make that failure observable.

For less critical events, an in-process buffer can be reasonable, but process exits and serverless freezes can drop buffered data. Do not describe an in-memory buffer as durable.

Batch without losing control#

POST /api/bulk-log accepts up to 50 events. A batch amortizes connections and leaves more room under the 40-ingestion-requests-per-second workspace guardrail.

Build batches by:

  • destination collection
  • a maximum of 50 events
  • a maximum wait, such as one second
  • a conservative total request size

Each serialized event must be no more than 10,000 characters. The jsons and timestamps arrays must be non-empty and have equal lengths.

Do not create one unbounded batch or an unbounded Promise.all. Both make a partial outage harder to recover from.

Apply a bounded retry policy#

Retry:

  • network failures
  • timeouts
  • 429
  • 500

Do not retry an unchanged 400; fix or quarantine the payload.

A practical schedule uses exponential backoff with jitter:

attempt 1: 250–500 ms
attempt 2: 500–1,000 ms
attempt 3: 1–2 s
attempt 4: 2–4 s

Set:

  • a timeout for each request
  • a maximum attempt count
  • an overall delivery deadline
  • a concurrency limit per worker

Jitter prevents many workers from retrying at exactly the same moment.

Reference worker#

This example accepts a batch that has already been read from a durable queue:

const ENDPOINT = "https://api.graphjson.com/api/bulk-log";

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function retryDelay(attempt) {
  const ceiling = Math.min(4000, 250 * 2 ** attempt);
  return Math.floor(ceiling / 2 + Math.random() * (ceiling / 2));
}

export async function deliverBatch(events, { maxAttempts = 5 } = {}) {
  const body = {
    api_key: process.env.GRAPHJSON_API_KEY,
    collection: "product_events",
    timestamps: events.map((event) =>
      Math.floor(new Date(event.occurred_at).getTime() / 1000)
    ),
    jsons: events.map((event) => JSON.stringify(event))
  };

  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(10_000)
      });

      if (response.ok) return;

      const message = await response.text();
      if (response.status === 400) {
        throw new PermanentDeliveryError(message);
      }
      if (response.status !== 429 && response.status < 500) {
        throw new PermanentDeliveryError(
          `Unexpected ${response.status}: ${message}`
        );
      }
    } catch (error) {
      if (error instanceof PermanentDeliveryError) throw error;
      if (attempt === maxAttempts - 1) throw error;
    }

    await wait(retryDelay(attempt));
  }
}

class PermanentDeliveryError extends Error {}

The queue handler should acknowledge its job only after deliverBatch returns. Exhausted and permanent failures belong in a dead-letter queue with the API key and sensitive payload fields redacted from logs.

Understand delivery semantics#

GraphJSON logging endpoints do not expose an idempotency-key mechanism. If a client loses the response, it may not know whether the event was accepted.

Design for at least once:

  1. keep a stable event_id
  2. retry uncertain deliveries
  3. accept that a duplicate can arrive
  4. deduplicate in SQL when a metric requires it

For example:

WITH
  JSONExtractString(json, 'event_id') AS event_id,
  JSONExtractString(json, 'event') AS event_name
SELECT count()
FROM
(
  SELECT event_id, any(event_name)
  FROM product_events
  WHERE event_id != ''
  GROUP BY event_id
)

This assumes every retry with one event_id has the same immutable payload. The deduplication guide covers latest-value and tie-breaking patterns for mutable business events.

Reconcile instead of trusting the happy path#

For important streams, compare GraphJSON with the source system:

  • source events created per hour
  • queue jobs completed, retried, and dead-lettered
  • GraphJSON events by event_id and source period
  • earliest and latest event time
  • duplicate and missing IDs

Reconciliation should use event time for the business period and ingestion time for pipeline delay. A backfill arriving today may correctly belong to last month.

Start a new integration with a small known sample. Verify counts, timestamps, field types, and identifiers before increasing traffic.

Keep analytics failures away from users#

For an ordinary product event:

  1. complete the user-facing transaction
  2. enqueue the event
  3. let a worker deliver it
  4. alert operators if the queue or dead-letter count grows

If the event cannot be enqueued, log a redacted operational error and follow the failure policy for that event class. Do not catch every failure silently; “analytics never breaks the request” should not mean “analytics can disappear unnoticed.”

Delivery checklist#

  • assign a stable source event_id
  • preserve the original event timestamp
  • separate business success from network delivery
  • use a durable queue for important events
  • batch at no more than 50 events
  • bound concurrency, timeouts, retries, and total delivery time
  • retry network failures, 429, and 500
  • quarantine unchanged 400 responses
  • dead-letter exhausted events
  • deduplicate when the metric requires it
  • monitor queue age and failure counts
  • reconcile a sample period against the source

For endpoint details, see bulk logging and errors and limits.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support