Gareth Wilson Gareth Wilson

Guide to Oyster Webhooks: Features and Best Practices

Published


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

FeatureDetails
RegistrationPOST https://api.oysterhr.com/v1/webhooks with url and sharedSecret
PrerequisitesA Developer Application and an access token for the same company
Method and formatPOST with a JSON body
Event identifiereventName, usually namespace.event_name
Idempotency keyid, prefixed wh_
AuthenticationAuthorization: Bearer <sharedSecret>. No signature
Response window200 within 5 seconds
RetriesUp to 9 attempts in total over about 48 hours, with jitter
Source IPsNot published

Common events

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

NamespaceExample events
hiringhiring.agreement_signed, hiring.employment_engaged, hiring.contractor_engaged, hiring.employment_onboarding_completed
contractcontract.change_requested, contract.change_approved, contract.change_applied
offboardingoffboarding.initiated, offboarding.approved, offboarding.engagement_terminated
expensesexpenses.expense_created, expenses.expense_approved, expenses.expense_declined
time_offtime_off.request_created, time_off.request_status_changed
time_trackingtime_tracking.time_sheet_submitted, time_tracking.time_sheet_approved
engagementsengagements.payslip_uploaded, engagements.document_uploaded
invoicinginvoicing.invoice_issued, invoicing.invoice_approved, invoicing.credit_note_issued
paymentspayments.charge_paid, payments.charge_failed, payments.charge_marked_as_overdue
payrollpayroll.settlement_invoice_created, payroll.pre_funding_invoice_created
benefitsbenefits.enrollment_requested, benefits.enrollment_enrolled
identity_verificationidentity_verification.approved, identity_verification.declined

There's also async_operation_completed, which has no namespace.

Every webhook follows the same pattern:

{
  "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:

    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.

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:

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 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.

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.

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 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 for free and handle Oyster 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.