Gareth Wilson Gareth Wilson

Guide to TriNet Zenefits Webhooks: Features and Best Practices

Published


TriNet Zenefits webhooks tell a partner application what is happening in the HR accounts of the companies that installed it: people are hired, change roles or leave, departments and locations change, time off is requested, and HR admins complete hiring, onboarding and termination flows. If you build an app on the Zenefits platform, now part of TriNet, webhooks are how those changes reach your service without polling the API.

This guide covers how Zenefits webhooks are delivered and signed, the events and payload shape, the retry rules, and the practices that keep a Zenefits receiver reliable.

What are TriNet Zenefits webhooks?

Zenefits webhooks belong to partner applications. You give the webhook URLs during integration setup, and Zenefits sends events as POST requests for every company that installs your app. The body names the event in type and carries the affected object in data, in the same shape as the REST API.

Every request has a header literally named signature, holding the hex HMAC-SHA256 of the raw body. The key is a shared secret that Zenefits generates for your application. It is not your OAuth client secret, and Zenefits warns about mixing the two up.

Zenefits's developer documentation now refers to "Trinet's HR platform", and the API examples use both api.zenefits.com and api.trinet.com hosts.

TriNet Zenefits webhook features

FeatureDetails
ConfigurationWebhook URLs provided during integration setup for your application
ScopeEvents for every company that installs your app
Event nametype in the body, as object.action[.subtype]
Signature headersignature
SchemeHMAC-SHA256, hex digest, no prefix
Signed contentThe raw request body only
SecretThe application's webhook shared secret. Not the OAuth client secret
TransportHTTPS only
AcknowledgementAny 2xx. Headers and body of the response are ignored
RetriesAnything else, 3xx included, is retried for up to 1 hour
DuplicatesPossible. Receivers must be idempotent
Source IPsNot published

Common events

Zenefits's events table lists 41 events. Most are a did_create and did_change pair for one kind of object:

AreaEvents
Companiescompanies.did_create, companies.did_change, company_banks.did_create, company_banks.did_change
Peoplepeople.did_create, people.did_change, banks.did_create, banks.did_change
Employmentemployments.did_create, employments.did_change, employments.will_start, employments.did_start, employments.did_terminate
Organisationdid_create and did_change for departments, locations, labor_groups, labor_group_types and equal_employment_opportunities
Custom fieldsdid_create and did_change for custom_fields and custom_field_values
Time off and timedid_create and did_change for vacation_requests, vacation_types and time_durations
Your appperson_subscriptions.did_enable, person_subscriptions.did_disable, company_installs.did_uninstall, company_installs.should_sync_install
Flowsflows.did_complete.hiring, flows.did_complete.onboarding, flows.did_complete.termination, flows.did_complete.employment_change

employments.will_start fires the day before an employment's start date, employments.did_start on the first day of work, and employments.did_terminate on the last. company_installs.did_uninstall fires when an HR admin removes your app.

The flow events are spelled inconsistently. The events table uses the plural flows.did_complete.hiring, but the webhooks page and its sample body use flow.did_complete.hiring, and the sentence introducing the sample calls it flow.complete.hiring. Accept both flow. and flows. until you've seen a real delivery.

The body has three top-level fields:

{
  "data": {
    "id": "1",
    "object": "/platform/flows",
    "type": "hiring",
    "url": "https://api.trinet.com/platform/flows/1",
    "company": { "id": "1", "name": "Company Inc", "object": "/core/companies" },
    "person": { "id": "1", "first_name": "John", "last_name": "Doe", "object": "/core/people" },
    "fields": {},
    "reserved_fields": {}
  },
  "object": "/meta/event",
  "type": "flow.did_complete.hiring"
}

That is Zenefits's sample, heavily trimmed. The full version nests the person's department, location, manager and bank accounts, with account and routing numbers, so treat payloads as sensitive. Related objects often arrive as references, { "object": "/meta/ref/detail", "url": "..." }, which you fetch from the API when you need them.

Setting up TriNet Zenefits webhooks

  1. In your Zenefits partner application, provide the HTTPS webhook URLs during integration setup.
  2. Find the application's webhook shared secret, and store it server-side, for example as ZENEFITS_WEBHOOK_SECRET. Don't use the OAuth client secret.
  3. Make sure the URL answers with a 2xx and doesn't redirect, because a 3xx counts as a failed delivery.

If you receive Zenefits webhooks through Hookdeck, give Zenefits the Hookdeck source URL and paste the webhook shared secret, not the OAuth client secret, into the source.

Securing TriNet Zenefits webhooks

Compute an HMAC-SHA256 of the raw body with the webhook shared secret, hex-encode it, and compare it with the signature header in constant time. Zenefits says that if anything else is in the header, you should treat the webhook as unauthorised and ignore its contents.

const crypto = require('crypto');
const express = require('express');

function verifyZenefitsSignature(rawBody, header, secret) {
  if (typeof header !== 'string' || !secret) return false;
  // Check the shape first: Buffer.from(x, 'hex') silently stops at invalid characters.
  if (!/^[0-9a-f]{64}$/i.test(header.trim())) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(header.trim(), 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/zenefits', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.ZENEFITS_WEBHOOK_SECRET; // webhook shared secret, not OAuth
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyZenefitsSignature(req.body, req.get('signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type (accept flow. and flows.), then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac


def verify_zenefits_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    # Compare bytes so a non-ASCII header returns False instead of raising.
    return hmac.compare_digest(header.strip().lower().encode("utf-8"), expected.encode("utf-8"))

Zenefits publishes a test vector you can use in a unit test. With the secret lDVtBQnoRcqYv4fUeVD0oyzgsKWHGtqhatjBX5wXRFQzkVUCzBA571E4qUxvR5LE and the body {"test":"valid payload"}, the signature is:

219a08ea32f319931dc849badc076323f40367aad859ddba4de82e33135fad31

The header name signature is generic, so make sure no proxy or framework in front of your service strips or rewrites it. Header names are case-insensitive, so Signature, as Zenefits prints it, is the same header.

Make TriNet Zenefits webhooks production-ready. Hookdeck Event Gateway verifies the signature header with your webhook shared secret, stores every people, employment and flow event, and delivers it to your services with retries that last longer than Zenefits's one hour.

TriNet Zenefits webhook limitations and pain points

Two secrets, one of them wrong

The Problem: Every delivery fails verification, even though the code is correct.

Why It Happens: A Zenefits partner app has an OAuth client secret and a separate webhook shared secret. Only the webhook shared secret signs webhooks, and the OAuth one produces a well-formed but wrong signature.

Workarounds:

  • Name the environment variables so the two can't be confused.
  • Run the test vector above to check your code, then a real delivery to check the secret.

How Hookdeck Can Help: Hookdeck's Zenefits source type reminds you to use the webhook shared secret, and marks requests that fail verification so you can see the problem straight away instead of losing events without notice.

A one-hour retry window

The Problem: An outage of more than an hour loses events.

Why It Happens: Zenefits retries a failed delivery for up to 1 hour, and a 3xx counts as a failure as well as 4xx and 5xx.

Workarounds:

  • Store the payload and return 2xx immediately, as Zenefits recommends, so retries only happen during real outages.
  • Reconcile people and employments from the API after a longer outage.

How Hookdeck Can Help: Hookdeck accepts and stores each delivery as it arrives, then delivers it to your service with automatic retries over as long a period as you configure.

Inconsistent event names

The Problem: A handler or filter built from the events table never matches the flow events.

Why It Happens: The events table says flows.did_complete.hiring, while the sample body says flow.did_complete.hiring. Only the singular form has been shown in a body.

Workarounds:

  • Match on both prefixes, or on the part after the first dot.
  • Log type for every delivery during development.

How Hookdeck Can Help: Hookdeck filters can match type against either spelling, and every received request is kept so you can check which one arrives.

No timestamp and possible duplicates

The Problem: The same message is processed twice, or a captured request is replayed.

Why It Happens: Zenefits says the same message may be delivered more than once. Only the body is signed, and no timestamp or delivery ID is sent.

Workarounds:

  • Make handlers idempotent, keyed on type and the data.id of the object.
  • Keep the endpoint on HTTPS, which Zenefits requires anyway.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on body fields such as type and data.id.

Best practices

Verify against the raw body

Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse only after the check passes.

Fetch references when you need them

Related objects often come as /meta/ref links with a url. Fetch them from the API with the company's OAuth token when you need the details, and cache what you use.

Handle uninstalls

company_installs.did_uninstall means an HR admin removed your app. Stop syncing that company and clean up its data according to your retention policy.

Protect personal and bank data

Payloads can include names, dates of birth, addresses, and bank account and routing numbers. Restrict who can read stored payloads and drop fields you don't need.

Acknowledge fast and process asynchronously

Store the payload, return 2xx, and process in a background task. See why to process webhooks asynchronously and our guide to webhook idempotency.

Conclusion

TriNet Zenefits signs each webhook with a header named signature, a bare hex HMAC-SHA256 of the raw body keyed with your application's webhook shared secret, not its OAuth client secret. Verify against the raw bytes, test with Zenefits's published vector, accept both flow. and flows. spellings, make handlers idempotent, and acknowledge quickly, because retries stop after an hour.

Hookdeck Event Gateway sits in front of your receiver, checks every Zenefits signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle TriNet Zenefits webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.