# Guide to TriNet Zenefits Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | Webhook URLs provided during integration setup for your application |
| Scope | Events for every company that installs your app |
| Event name | `type` in the body, as `object.action[.subtype]` |
| Signature header | `signature` |
| Scheme | HMAC-SHA256, hex digest, no prefix |
| Signed content | The raw request body only |
| Secret | The application's webhook shared secret. Not the OAuth client secret |
| Transport | HTTPS only |
| Acknowledgement | Any `2xx`. Headers and body of the response are ignored |
| Retries | Anything else, `3xx` included, is retried for up to 1 hour |
| Duplicates | Possible. Receivers must be idempotent |
| Source IPs | Not published |

## Common events

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

| Area | Events |
| --- | --- |
| Companies | `companies.did_create`, `companies.did_change`, `company_banks.did_create`, `company_banks.did_change` |
| People | `people.did_create`, `people.did_change`, `banks.did_create`, `banks.did_change` |
| Employment | `employments.did_create`, `employments.did_change`, `employments.will_start`, `employments.did_start`, `employments.did_terminate` |
| Organisation | `did_create` and `did_change` for `departments`, `locations`, `labor_groups`, `labor_group_types` and `equal_employment_opportunities` |
| Custom fields | `did_create` and `did_change` for `custom_fields` and `custom_field_values` |
| Time off and time | `did_create` and `did_change` for `vacation_requests`, `vacation_types` and `time_durations` |
| Your app | `person_subscriptions.did_enable`, `person_subscriptions.did_disable`, `company_installs.did_uninstall`, `company_installs.should_sync_install` |
| Flows | `flows.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:

```json
{
  "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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) sits in front of your receiver, checks every Zenefits signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle TriNet Zenefits webhooks reliably in minutes.