DEV Community

Mahir Amaan
Mahir Amaan

Posted on

Hire Gusto and ADP Expert to Prevent Duplicate Events

A payroll webhook can arrive more than once, even when your application is functioning correctly. If your system processes every delivery as a new event, it can trigger duplicate operations, inconsistent payroll records, or repeated downstream actions.

When businesses hire Gusto and ADP experts, preventing duplicate processing should be an important part of integration planning. The challenge is not simply receiving a webhook. It is ensuring that retries, concurrent requests, and application failures do not produce unintended business outcomes.

In this tutorial, we'll build a durable webhook ingestion workflow using Node.js, Express, and PostgreSQL. The implementation demonstrates database-enforced idempotency, event verification, and asynchronous processing.

Prerequisites

Before getting started, you'll need:

  • Node.js 20
  • Express 4.21.2
  • PostgreSQL 16
  • pg 8.13.1 for PostgreSQL connectivity
  • A configured Gusto developer integration with webhook access

This example focuses on webhook ingestion and duplicate prevention. Gusto and ADP have different integration contracts, so their authentication methods, payload structures, and event identifiers must be handled separately.

Why Can Duplicate Webhooks Cause Problems?

Consider a webhook handler that immediately processes every incoming request:

app.post("/webhook", async (req, res) => {
  await processPayrollEvent(req.body);
  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

This approach has a fundamental limitation: it doesn't record whether the event has already been processed.

If the provider retries a delivery after a timeout, the same operation might execute again. Multiple application instances can also process identical requests concurrently.

An in-memory Set or similar mechanism isn't a reliable solution because its contents disappear when the application restarts and aren't shared across instances.

Gusto documents webhook delivery and retry behavior in its official webhook documentation. Your application should therefore tolerate repeated deliveries rather than assume each event will arrive exactly once.

The solution is to make duplicate detection part of the database layer.

Step 1: Create a Durable Webhook Inbox

Start by storing incoming events before triggering payroll-related business operations.

PostgreSQL provides persistent storage and unique constraints, allowing the application to recognize duplicate events even when multiple requests arrive simultaneously.

Create the following table:

CREATE TABLE payroll_webhook_events (
    provider TEXT NOT NULL,
    event_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    status TEXT NOT NULL DEFAULT 'received',
    attempts INTEGER NOT NULL DEFAULT 0,
    last_error TEXT,
    received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processed_at TIMESTAMPTZ,
    PRIMARY KEY (provider, event_id),
    CHECK (
        status IN (
            'received',
            'processing',
            'processed',
            'failed'
        )
    )
);
Enter fullscreen mode Exit fullscreen mode

The primary key prevents the same event from being inserted multiple times for the same provider.

The provider field also makes the table suitable for applications that eventually support multiple payroll integrations.

Use the provider's unique event identifier rather than an employee ID or payroll ID. Different events can legitimately relate to the same employee or payroll run.

The stored payload also provides an audit trail for investigating failed events and implementing controlled replay procedures. Because payroll payloads may contain sensitive employee information, restrict access and define appropriate retention policies.

Step 2: Verify Webhook Authenticity

Deduplication alone doesn't establish whether a request is legitimate. Before accepting an event, your application must verify that it came from the expected provider.

Gusto's verification requirements depend on the integration type and API version. Review the official Gusto webhook security documentation before implementing signature verification.

The signature must be verified against the original request bytes. Parsing and reserializing JSON before verification can change the payload representation and cause verification to fail.

Install the required packages:

npm install --save-exact express@4.21.2 pg@8.13.1
Enter fullscreen mode Exit fullscreen mode

Configure the necessary environment variables:

export DATABASE_URL="postgresql://localhost:5432/payroll"
export GUSTO_WEBHOOK_SECRET="your_verified_webhook_token"
export PORT="3000"
Enter fullscreen mode Exit fullscreen mode

Keep credentials outside your source code and use your deployment environment's secret-management facilities.

The following example illustrates HMAC-SHA256 verification when that is the signature format specified by your integration:

const crypto = require("node:crypto");

function verifyGustoSignature(rawBody, signature, secret) {
  if (!signature || !secret) {
    return false;
  }

  const supplied = Buffer.from(signature, "hex");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest();

  if (supplied.length !== expected.length) {
    return false;
  }

  return crypto.timingSafeEqual(supplied, expected);
}
Enter fullscreen mode Exit fullscreen mode

Important: This is a format-specific example, not a universal Gusto verification implementation. Confirm the actual signing algorithm, header format, and secret-handling requirements for your integration before using it in production.

Step 3: Prevent Duplicate Events with PostgreSQL

After verifying the request, extract its unique event identifier and attempt to insert the event into the inbox.

PostgreSQL's INSERT ... ON CONFLICT DO NOTHING provides an atomic way to handle duplicate insert attempts. Unlike a separate database lookup followed by an insert, this approach relies on the unique constraint to resolve concurrent requests.

Here's an Express endpoint demonstrating the pattern:

const express = require("express");
const { Pool } = require("pg");

const app = express();

const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

app.post(
  "/webhooks/gusto",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    try {
      const secret = process.env.GUSTO_WEBHOOK_SECRET;
      const signature = req.get("X-Gusto-Signature");

      if (!verifyGustoSignature(
        req.body,
        signature,
        secret
      )) {
        return res.status(401).json({
          error: "Invalid signature"
        });
      }

      const event = JSON.parse(req.body.toString("utf8"));

      if (!event.uuid || !event.event_type) {
        return res.status(400).json({
          error: "Invalid event"
        });
      }

      const result = await pool.query(
        `INSERT INTO payroll_webhook_events
          (provider, event_id, event_type, payload)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (provider, event_id) DO NOTHING
         RETURNING event_id`,
        [
          "gusto",
          event.uuid,
          event.event_type,
          event
        ]
      );

      return res.status(200).json({
        accepted: true,
        duplicate: result.rowCount === 0
      });
    } catch (error) {
      if (error instanceof SyntaxError) {
        return res.status(400).json({
          error: "Malformed JSON"
        });
      }

      console.error(
        "Webhook ingestion failed:",
        error.message
      );

      return res.status(500).json({
        error: "Ingestion failed"
      });
    }
  }
);

app.listen(Number(process.env.PORT || 3000));
Enter fullscreen mode Exit fullscreen mode

This example assumes that the event contains uuid and event_type fields and that the signature header uses the expected format. Confirm these details against the current provider contract before deployment.

The endpoint returns a successful response when an event has already been recorded. This prevents an ordinary duplicate delivery from being treated as a new business operation.

If the database insert fails, the endpoint returns an error instead of acknowledging an event that hasn't been stored.

The key principle: Persist the event before acknowledging successful receipt. Otherwise, an application crash could result in a lost event that the provider believes was delivered successfully.

Step 4: Process Events Asynchronously

Receiving an event and completing its business operation are two different responsibilities.

The webhook endpoint should focus on verification and durable storage. A background worker can then retrieve pending events and process them independently.

PostgreSQL's FOR UPDATE SKIP LOCKED helps multiple workers claim different records without unnecessarily blocking one another.

For example:

BEGIN;

WITH next_event AS (
    SELECT provider, event_id
    FROM payroll_webhook_events
    WHERE status = 'received'
    ORDER BY received_at
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE payroll_webhook_events AS e
SET status = 'processing',
    attempts = attempts + 1
FROM next_event AS n
WHERE e.provider = n.provider
  AND e.event_id = n.event_id
RETURNING e.*;

COMMIT;
Enter fullscreen mode Exit fullscreen mode

The worker can process the returned record after the claim transaction commits. This avoids holding a database lock throughout a potentially slow external operation.

However, a worker might crash after claiming an event. Without a recovery mechanism, the event could remain stuck in the processing state.

A production implementation should use a lease, claim timestamp, or equivalent recovery mechanism. A scheduled process can identify stale claims and make them available for retry.

Recovery must also account for whether the business operation might already have succeeded before the worker crashed.

Step 5: Make Business Operations Idempotent

Preventing duplicate event insertion is essential, but it doesn't guarantee that every business operation executes only once.

Two different events may refer to the same payroll run or downstream action. Event-level deduplication won't automatically protect against these cases.

Use a business-level idempotency key wherever possible.

For example, a payroll export might use a combination of:

  • Provider
  • Payroll identifier
  • Export type

When the business rules permit it, enforce uniqueness for that combination in the database.

For internal database operations, update the business records and mark the event as processed within the same transaction whenever possible.

External API calls require additional care. A database transaction cannot roll back an operation that has already succeeded in another system.

If an external request succeeds but your application crashes before updating the local event status, retrying the event could repeat that operation. Use the destination's idempotency mechanism when available, or implement a reconciliation strategy for uncertain outcomes.

This distinction is particularly important in payroll systems, where repeated exports, notifications, or downstream actions can create operational confusion.

Step 6: Test Failure Scenarios

Before deploying the integration, verify that it behaves correctly under normal and failure conditions.

Test scenario Expected result
Submit a valid event One inbox record is created.
Submit the same event again No additional record is created.
Submit identical requests concurrently Only one record exists for the provider and event ID.
Submit an invalid signature The request is rejected before insertion.
Submit malformed JSON The endpoint returns a client error.
Temporarily stop the database The endpoint returns an error instead of acknowledging an unstored event.
Crash a worker after claiming an event Recovery identifies the stale processing record.
Retry a failed business operation The operation follows the defined idempotency and recovery rules.

You can inspect the inbox using the following query:

SELECT
    provider,
    event_id,
    status,
    attempts,
    received_at,
    processed_at
FROM payroll_webhook_events
ORDER BY received_at DESC;
Enter fullscreen mode Exit fullscreen mode

For duplicate deliveries, verify that only one inbox record exists for the event.

Also test the actual business effect. A unique database row doesn't prove that an external operation, such as an export or notification, occurred only once.

Production Considerations

A PostgreSQL inbox is a practical starting point for applications that need durable event storage without immediately introducing a separate message broker.

However, production systems also need operational safeguards.

Retry and failure handling

Define retry intervals, maximum attempts, and procedures for handling permanently failed events. Failed records should remain available for investigation without being retried indefinitely.

Event ordering

Webhook deliveries don't necessarily arrive in the order your business logic expects. If ordering matters, use provider-supported resource retrieval, version checks, or explicit sequencing rules.

Monitoring and alerting

Track processing failures, retry counts, stale worker claims, and the age of the oldest unprocessed event. Alerts can help teams identify integration issues before they affect payroll operations.

Data security and retention

Payroll events may contain sensitive employee information. Apply least-privilege database access, secure secret storage, appropriate encryption, and documented retention rules.

Choosing a message broker

A database inbox may be sufficient for a smaller workload. A message broker becomes more attractive when you need independent consumers, advanced routing, higher throughput, or built-in dead-letter handling.

Choose the architecture according to your operational requirements and the team's ability to monitor, troubleshoot, and recover failures.

Finally, Gusto and ADP have different API contracts, product requirements, and authentication models. ADP integration decisions should be based on the relevant ADP developer API guides, rather than assuming that the same provider-specific implementation will work for both platforms.

Applying These Principles to Payroll Software Development

Oodles Technologies has worked on payroll application components for Boston Payroll, a US-focused payroll service provider. The documented project scope included centralized employee records, automated payroll calculations, streamlined workflows, and reporting aligned with US payroll requirements.

This experience provides relevant context for building payroll applications where accurate records, traceable processing, and dependable workflows matter.

The available project information does not confirm that the specific webhook-inbox architecture described in this tutorial was deployed, so it should not be presented as a completed Gusto or ADP integration.

For organizations developing or improving a Payroll Management System, the broader lesson is to separate event reception from business processing. This creates a clearer foundation for retries, recovery, monitoring, and testing.

Key Takeaways

Reliable payroll webhook processing requires more than a successful HTTP response.

  • Enforce event uniqueness at the database level.
  • Verify incoming requests using the provider's documented security mechanism.
  • Store events durably before acknowledging receipt.
  • Process events asynchronously when business operations could delay the response.
  • Recover stale worker claims and handle failed events deliberately.
  • Protect business operations against duplicate effects, including those triggered by different event IDs.
  • Test concurrent deliveries, application crashes, retries, and recovery procedures.

When you hire Gusto and ADP experts, make retry handling, event ordering, idempotency, and recovery part of the integration review. These considerations help create payroll integrations that are easier to maintain and troubleshoot.

If your team is planning a new payroll integration or addressing duplicate-processing issues, contact our team to discuss your requirements.

Frequently Asked Questions

Does Gusto guarantee that each webhook arrives only once?

You should not assume exactly-once delivery. Gusto documents webhook retries when acknowledgments fail, so receivers should tolerate repeated deliveries. A database uniqueness constraint helps prevent the same event ID from being recorded more than once.

Can the same webhook handler be used for Gusto and ADP?

The overall architecture can be shared, but provider-specific adapters are still necessary. Authentication, payload structures, event identifiers, and supported operations may differ. Keep these differences isolated rather than assuming both providers expose identical events.

Why isn't an in-memory idempotency key sufficient?

An in-memory key disappears when the application restarts and isn't shared between application instances. Concurrent requests can also arrive before the key is recorded. A database constraint provides persistent deduplication and coordinates competing insert attempts.

Should payroll webhook processing happen inside the HTTP handler?

The handler should generally focus on verification and durable event acceptance. A background worker can process business operations independently, making retries, monitoring, and recovery easier to manage.

When should a team use a message broker instead of a database inbox?

A message broker may be a better fit when an application needs multiple independent consumers, advanced routing, sustained high throughput, or dedicated dead-letter handling. A database inbox can be a reasonable starting point for smaller workloads. The decision should reflect processing requirements, recovery needs, and operational capacity.

Top comments (0)