# Guide to Oyster Webhooks: Features and Best Practices

Oyster webhooks tell your systems what's happening across your global workforce: a contractor is engaged, an employment agreement is signed, an expense is approved, a payslip is uploaded, a time-off request changes or an invoice is issued. If you hire or pay people through Oyster, the global employment platform, or build on Oyster as a partner, webhooks are how those changes reach your HRIS, finance tools and internal workflows without polling the Oyster API.

This guide covers how Oyster webhook endpoints are registered, how to authenticate deliveries with the shared secret, the event namespaces and payload pattern, Oyster's timeout and retry schedule, and the best practices for production.

## What are Oyster webhooks?

Oyster sends webhooks to endpoints you register through its API, not through a dashboard. You need a Developer Application and an access token authorised for the same company that owns it. With those, you register a URL and a shared secret, and Oyster starts sending a JSON `POST` to the URL as events happen.

Oyster doesn't sign webhooks. Instead, it sends your shared secret back as a bearer token in the `Authorization` header of every request, "to prove they've come from the Oyster platform". If your secret is `12345`, the header is `Authorization: Bearer 12345`. Your receiver compares the header with the secret it registered.

## Oyster webhook features

| Feature | Details |
| --- | --- |
| Registration | `POST https://api.oysterhr.com/v1/webhooks` with `url` and `sharedSecret` |
| Prerequisites | A Developer Application and an access token for the same company |
| Method and format | `POST` with a JSON body |
| Event identifier | `eventName`, usually `namespace.event_name` |
| Idempotency key | `id`, prefixed `wh_` |
| Authentication | `Authorization: Bearer <sharedSecret>`. No signature |
| Response window | `200` within 5 seconds |
| Retries | Up to 9 attempts in total over about 48 hours, with jitter |
| Source IPs | Not published |

## Common events

Oyster documents 97 event types. They're grouped by namespace:

| Namespace | Example events |
| --- | --- |
| `hiring` | `hiring.agreement_signed`, `hiring.employment_engaged`, `hiring.contractor_engaged`, `hiring.employment_onboarding_completed` |
| `contract` | `contract.change_requested`, `contract.change_approved`, `contract.change_applied` |
| `offboarding` | `offboarding.initiated`, `offboarding.approved`, `offboarding.engagement_terminated` |
| `expenses` | `expenses.expense_created`, `expenses.expense_approved`, `expenses.expense_declined` |
| `time_off` | `time_off.request_created`, `time_off.request_status_changed` |
| `time_tracking` | `time_tracking.time_sheet_submitted`, `time_tracking.time_sheet_approved` |
| `engagements` | `engagements.payslip_uploaded`, `engagements.document_uploaded` |
| `invoicing` | `invoicing.invoice_issued`, `invoicing.invoice_approved`, `invoicing.credit_note_issued` |
| `payments` | `payments.charge_paid`, `payments.charge_failed`, `payments.charge_marked_as_overdue` |
| `payroll` | `payroll.settlement_invoice_created`, `payroll.pre_funding_invoice_created` |
| `benefits` | `benefits.enrollment_requested`, `benefits.enrollment_enrolled` |
| `identity_verification` | `identity_verification.approved`, `identity_verification.declined` |

There's also `async_operation_completed`, which has no namespace.

Every webhook follows the same pattern:

```json
{
  "eventName": "expenses.expense_approved",
  "eventTimestamp": "2026-04-21T09:35:13Z",
  "id": "wh_01J19XRFZ2Q4QBDC6CA461AGT7",
  "companyId": "ABC1234",
  "data": {
    "...": "fields specific to the event, such as the ID of the approved expense"
  }
}

```

* `eventName` names the event. Oyster suggests filtering on it for the events you care about.
* `eventTimestamp` is when the event happened, in ISO 8601 UTC. Oyster suggests using it for ordering and for debugging delayed deliveries.
* `id` is unique per notification. If several developer applications receive the same event, each gets a different `id`.
* `companyId` identifies the company. Partners use it to match the event to the right customer.

Oyster's per-event reference pages publish a schema for `data` rather than an example body, so check the reference page for each event you handle.

## Setting up Oyster webhooks

1. Generate a long random shared secret, for example with `openssl rand -hex 32`.
2. Create a Developer Application, if you don't have one, and get an access token for the same company.
3. Register your endpoint:
  
  
  ```bash
  curl --request POST \
    --url https://api.oysterhr.com/v1/webhooks \
    --header 'accept: application/json' \
    --header "authorization: Bearer $OYSTER_ACCESS_TOKEN" \
    --header 'content-type: application/json' \
    --data '{
      "url": "https://example.com/webhooks/oyster",
      "sharedSecret": "YOUR_LONG_RANDOM_SECRET"
    }'
  
  ```
  
  
4. Keep the webhook endpoint ID the API returns. You need it to update or delete the endpoint later.

**In Hookdeck.** Create a source with the Oyster type and paste the `sharedSecret` you registered into **Shared Secret**. Use the source URL as the `url` when you register the endpoint. Hookdeck expects Oyster's `Bearer ` prefix in front of that secret, so you don't need to type it, and pasting the full `Bearer YOUR_LONG_RANDOM_SECRET` value works too.

## Securing Oyster webhooks

Read the `Authorization` header and compare it with `Bearer ` plus your shared secret, in constant time.

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

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/oyster', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.OYSTER_SHARED_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed when unconfigured

  const received = req.get('authorization');
  if (typeof received !== 'string' || !safeEqual(received, `Bearer ${secret}`)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  // Store event.id, then queue event.eventName / event.data for a worker.
  res.sendStatus(200); // must arrive within 5 seconds
});

```

The same check in Python:

```python
import hmac
import os

def verify_oyster_request(headers) -> bool:
    secret = os.environ.get("OYSTER_SHARED_SECRET")
    if not secret:
        return False  # fail closed
    received = headers.get("authorization")
    if not received:
        return False
    expected = f"Bearer {secret}"
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

A matching header proves the sender knows your shared secret. It doesn't prove that the body is unchanged, and a captured request can be replayed later and still pass, because Oyster sends no signature and the timestamp isn't signed. Serve the endpoint over HTTPS only, use a long random secret, and update it through the webhooks API if it may have leaked.

> **Make Oyster webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the bearer secret on every Oyster webhook, answers inside the 5-second window, and queues each event durably with replay.

## Oyster webhook limitations and pain points

### Five seconds to answer

**The Problem:** A handler that updates your HRIS or payroll system before responding times out, and Oyster sends the event again.

**Why It Happens:** Oyster requires a `200` within 5 seconds and treats anything else as a failed dispatch. It then retries up to 9 times in total over about 48 hours, after 30 seconds, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 1 day.

**Workarounds:**

* Verify, store the event and return `200` immediately, then process it in a background job.
* Make processing idempotent on `id`, because Oyster says webhooks may be delivered more than once.

**How Hookdeck Can Help:** Hookdeck answers Oyster as soon as each webhook arrives and stores it, then delivers it to your service on your own schedule and timeout, so slow processing doesn't cause retries.

### A bearer secret, not a signature

**The Problem:** Anyone who learns the shared secret can send convincing HR events, such as an approved expense or a terminated engagement.

**Why It Happens:** Oyster repeats the same bearer value on every request. Nothing is computed over the body.

**Workarounds:**

* Use a long random secret, HTTPS only, and keep it out of logs.
* For sensitive events, re-read the resource from the Oyster API using the IDs in `data` before acting on it.

**How Hookdeck Can Help:** Hookdeck's Oyster source type checks that the `Authorization` header carries your shared secret after the `Bearer ` prefix. Hookdeck answers Oyster with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination.

### No dashboard for endpoints

**The Problem:** Changing the URL or rotating the secret means an API call, and losing the endpoint ID makes it harder to manage.

**Why It Happens:** Oyster webhook endpoints are created, updated and deleted through the API with the ID returned at creation.

**Workarounds:**

* Store the endpoint ID with your configuration, next to the secret.
* Script rotation: update the secret through the API, then update your receiver, and accept both values briefly while retries drain.

**How Hookdeck Can Help:** If you point Oyster at a Hookdeck source, the URL Oyster holds stays the same while you move or change the service behind it, so most changes don't need an Oyster API call at all.

### Events can arrive late or out of order

**The Problem:** A retried `time_off.request_created` arrives after the matching `time_off.request_status_changed`.

**Why It Happens:** Retries over 48 hours mean an older event can land after a newer one.

**Workarounds:**

* Compare `eventTimestamp` with the last state you stored before applying a change, as Oyster suggests.
* Re-fetch the resource when order matters.

**How Hookdeck Can Help:** Every webhook is stored in Hookdeck with its body and arrival time, so you can see what arrived when, and replay events in order after fixing your handler.

## Best practices

### Store the full header value

Keep `Bearer <secret>`, or build it in code, and compare the whole value. A comparison against the bare secret will reject every request.

### Deduplicate on `id`

Each notification `id` is unique and prefixed `wh_`. Store it before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Route on namespace, then event

Split `eventName` on the first dot and route by namespace first, so new events in a namespace you already handle reach the right code. Acknowledge unknown events with `200`.

### Use `companyId` if you're a partner

Partners receive events for many customers. Look up the customer by `companyId` before doing anything else.

### Acknowledge fast and process asynchronously

Five seconds is tight for HR and finance systems. Verify, persist, respond, then process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Oyster sends a JSON `POST` for each event, with your shared secret as a bearer token in the `Authorization` header as the only credential. Compare `Bearer <secret>` in constant time over HTTPS, deduplicate on `id`, route on `eventName`, use `eventTimestamp` for ordering, and answer within 5 seconds.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the bearer secret, answers Oyster immediately, and keeps every event until your service has processed it.

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