# Oyster Webhooks

Oyster (global HR and employer-of-record platform) pushes events such as hires, expenses,
contract changes and benefits enrollments to an endpoint you register with
`POST /v1/webhooks`. Payloads are **not signed**. The only authentication is the
`sharedSecret` you chose, which Oyster sends back as an `Authorization` bearer header.

## When to Use This Skill

* How do I receive Oyster webhooks?
* How do I verify the Oyster `Authorization: Bearer <sharedSecret>` header?
* Does Oyster sign webhook payloads? (No: shared secret in the Authorization header only)
* Why does my Oyster webhook endpoint return 401?
* How do I handle `hiring.employment_engaged` or `expenses.expense_approved` events?

## Verification (core)

When you register the endpoint you send `{"url": "...", "sharedSecret": "12345"}`. Oyster
then sends `Authorization: Bearer 12345` on every webhook. Treat the **whole header value,
scheme prefix included**, as the credential and compare it in full. Don't strip or parse
the `Bearer ` prefix:

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

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(String(a)).digest();
  const hb = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifyOysterRequest(headers, expected = process.env.OYSTER_AUTHORIZATION) {
  if (!expected) return false;                 // never accept when unconfigured
  const received = headers['authorization'];   // full value, e.g. "Bearer 12345"
  return Boolean(received) && safeEqual(received, expected);
}

```

Return `401` when this fails, `400` for a body that isn't a JSON object with an `eventName`,
and `200` once handled (Oyster needs a `200` within 5 seconds). Read the body raw
(`express.raw({ type: '*/*' })`) so any content type works, and parse JSON only after the
check. Python and Next.js equivalents are in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/oyster-webhooks/references/verification.md).

**What this does and doesn't prove:** a static shared credential authenticates the *sender*,
not the *body*. There is no signature and no timestamp header, so nothing protects the
payload from tampering by anything that can see the request, and a captured request can be
replayed. Use HTTPS only, dedupe on the event `id`, and rotate the shared secret (PATCH the
webhook endpoint) if it leaks.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/oyster-webhooks/examples/express/),
> [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/oyster-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/oyster-webhooks/examples/fastapi/).

## Event Types

Every payload has the same envelope: `eventName`, `eventTimestamp` (ISO 8601 UTC), `id`
(globally unique, prefixed `wh_`), `companyId` and an event-specific `data` object. Names are
`namespace.event_name`.

| `eventName` | Fires when | `data` keys |
| --- | --- | --- |
| `hiring.employment_engaged` | An EOR employment is engaged | `engagementId` |
| `expenses.expense_approved` | An expense is approved | `expenseId`, `reviewState`, `previousReviewState` |
| `contract.change_applied` | A contract change is applied | `engagementChangeId`, `changeType`, `state` |
| `benefits.enrollment_enrolled` | A benefits enrollment is enrolled | `engagementId`, `enrollmentId` |

Oyster documents many more (hiring, expenses, invoicing, payments, offboarding, time off,
time tracking and more). See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/oyster-webhooks/references/overview.md).

## Delivery

Respond `200` within 5 seconds. Otherwise Oyster retries up to 9 total attempts over about
48 hours (30s, 5m, 30m, 1h, 3h, 6h, 12h, 1d, plus jitter), so handlers must be idempotent:
dedupe on `id`.

## Environment Variables

```bash
OYSTER_AUTHORIZATION=Bearer 12345   # full header value: "Bearer " + the sharedSecret you registered

```

## Local Development

```bash
npx hookdeck-cli listen 3000 oyster --path /webhooks/oyster

```

No account required. Register the printed URL as the webhook `url` with a `sharedSecret` of
your choice, and set `OYSTER_AUTHORIZATION` to `Bearer <that secret>`.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/oyster-webhooks/references/overview.md) - Envelope, event namespaces, delivery and retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/oyster-webhooks/references/setup.md) - Registering the endpoint with `POST /v1/webhooks`
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/oyster-webhooks/references/verification.md) - Header check in Node, Python and Next.js, gotchas
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/oyster-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests