# Webhooks

This guide walks you through setting up webhook endpoints to receive real-time event notifications from the Friday API. By the end you'll know how to register an endpoint, verify incoming signatures, handle retries, and respond to each event type.

## Overview

Instead of polling the API for changes, you can register a **webhook endpoint** — an HTTPS URL on your server — and Friday will push events to it automatically. When something changes in the company (an employee is created, a time record is updated, a payroll is approved, etc.), Friday sends an HTTP `POST` request to every active endpoint registered for that company.

Your server must respond with a `2xx` status code within **10 seconds** to acknowledge receipt.

***

## Before You Start

* **Developer Portal access** — You need API access for the company in the Developer Portal. See the [Getting API Access guide](./getting-api-access) if you haven't set this up yet.
* **HTTPS endpoint** — Your webhook receiver must be reachable over HTTPS. Friday will not deliver to plain HTTP URLs.
* **No plan requirement** — Webhook registration is available on all plans. The events you receive depend on the company's plan (e.g., payroll events only fire for companies on a payroll plan).

***

## The Setup Flow

```
┌──────────────────────────────────────────────┐
│  1. Register a webhook endpoint              │  ← Developer Portal
└──────────────────┬───────────────────────────┘
                   ▼
┌──────────────────────────────────────────────┐
│  2. Copy and store the signing secret        │  ← Shown once
└──────────────────┬───────────────────────────┘
                   ▼
┌──────────────────────────────────────────────┐
│  3. Build your webhook receiver              │  ← Your server
└──────────────────┬───────────────────────────┘
                   ▼
┌──────────────────────────────────────────────┐
│  4. Verify signatures on incoming requests   │  ← Prevent spoofing
└──────────────────┬───────────────────────────┘
                   ▼
┌──────────────────────────────────────────────┐
│  5. Process events and respond with 2xx      │  ← Acknowledge receipt
└──────────────────────────────────────────────┘
```

***

## Step 1 — Register a Webhook Endpoint

Log in to the [Developer Portal](https://developer.friday-staging.com) and navigate to the **Webhooks** page.

1. Click **Register Endpoint**.
2. Select the **company** you want to receive events for (only companies with accepted invitations appear).
3. Enter your **HTTPS URL** — this is where Friday will send event payloads.
4. Optionally add a **description** to identify the endpoint (e.g., "Production", "Staging", "Sync service").
5. Click **Create**.

Each endpoint is scoped to a single company. If you integrate with multiple companies, register a separate endpoint for each.

***

## Step 2 — Copy the Signing Secret

After creation, the portal displays a **signing secret** — a 64-character hex string. This secret is used to verify that incoming webhook requests actually came from Friday.

> **Copy it now and store it securely.** The secret is only shown once. If you lose it, delete the endpoint and create a new one.

You'll use this secret in your webhook receiver to validate the `X-Friday-Signature` header on every incoming request.

***

## Step 3 — Build Your Webhook Receiver

Your receiver is a standard HTTPS endpoint that accepts `POST` requests. Here's a minimal example in Node.js:

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

const app = express();

app.post('/webhooks/friday', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.FRIDAY_WEBHOOK_SECRET;
  const signature = req.headers['x-friday-signature'];
  const timestamp = req.headers['x-friday-timestamp'];
  const body = req.body.toString();

  // 1. Verify the signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + body)
    .digest('hex');

  if (signature !== 'sha256=' + expected) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Reject stale timestamps (> 5 minutes old)
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    return res.status(401).send('Timestamp too old');
  }

  // 3. Process the event
  const event = JSON.parse(body);
  console.log(`Received ${event.event_type} for company ${event.company_id}`);

  // 4. Respond with 200 to acknowledge
  res.status(200).send('OK');
});

app.listen(3000);
```

And the equivalent in Python (Flask):

```python
import hmac
import hashlib
import time
import json
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_signing_secret_here"

@app.route("/webhooks/friday", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Friday-Signature", "")
    timestamp = request.headers.get("X-Friday-Timestamp", "")
    body = request.get_data(as_text=True)

    # 1. Verify signature
    expected = hmac.new(
        WEBHOOK_SECRET.encode(), (timestamp + "." + body).encode(), hashlib.sha256
    ).hexdigest()

    if signature != f"sha256={expected}":
        abort(401)

    # 2. Reject stale timestamps
    if abs(time.time() - int(timestamp)) > 300:
        abort(401)

    # 3. Process the event
    event = json.loads(body)
    print(f"Received {event['event_type']} for company {event['company_id']}")

    return "OK", 200
```

***

## Step 4 — Verify Signatures

Every webhook request from Friday includes an HMAC-SHA256 signature so you can confirm authenticity. The signature is computed over the concatenation of the timestamp, a literal `.` character, and the raw JSON body.

### Headers included with every delivery

| Header                | Description                                              |
| --------------------- | -------------------------------------------------------- |
| `Content-Type`        | Always `application/json`                                |
| `X-Friday-Signature`  | HMAC-SHA256 signature, prefixed with `sha256=`           |
| `X-Friday-Timestamp`  | Unix timestamp (seconds) when the webhook was sent       |
| `X-Friday-Event-Id`   | Unique identifier for this event (use for deduplication) |
| `X-Friday-Event-Type` | The event type string, e.g. `employee.created`           |

### Verification steps

1. Read the `X-Friday-Timestamp` and `X-Friday-Signature` headers.
2. Compute the expected signature: `HMAC-SHA256(secret, timestamp + "." + rawBody)`.
3. Compare the result to the signature header (strip the `sha256=` prefix first).
4. Reject the request if the signatures don't match.
5. Reject the request if the timestamp is more than 5 minutes old (prevents replay attacks).

> **Important** — Make sure you're comparing against the **raw JSON body** (the exact bytes received), not a re-serialized version. Re-serializing can change key order or whitespace, which would break the signature.

***

## Step 5 — Handle Events

### Payload format

Every webhook delivery has the same top-level structure:

```json
{
  "event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "event_type": "employee.created",
  "created_at": "2026-03-19T12:00:00.000Z",
  "company_id": 42,
  "data": { ... }
}
```

| Field        | Description                                                                                             |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| `event_id`   | A unique identifier for this event. Use it to deduplicate if you receive the same event more than once. |
| `event_type` | A dot-separated string identifying what happened (e.g., `employee.created`, `payroll.approved`).        |
| `created_at` | ISO 8601 timestamp of when the event occurred.                                                          |
| `company_id` | The ID of the company this event belongs to.                                                            |
| `data`       | The full resource object, matching the same shape as the corresponding API response for that resource.  |

### Idempotency

Friday may deliver the same event more than once (due to retries or network issues). Use the `event_id` field to detect duplicates. A simple approach is to store processed event IDs and skip any you've already seen.

### Respond quickly

Your handler should return a `2xx` response within **10 seconds**. If your processing takes longer, acknowledge the webhook immediately and handle the work asynchronously (e.g., write the event to a queue and process it in a background worker).

***

## Retries and Failure Handling

If your endpoint returns a non-`2xx` status code or doesn't respond within 10 seconds, Friday marks the delivery as **failed** and retries automatically with exponential backoff:

| Attempt   | Delay after failure |
| --------- | ------------------- |
| 1st retry | 10 seconds          |
| 2nd retry | 1 minute            |
| 3rd retry | 5 minutes           |
| 4th retry | 30 minutes          |

After all retry attempts are exhausted, the delivery is marked as **exhausted** and no further automatic retries occur.

### Delivery statuses

| Status      | Meaning                                              |
| ----------- | ---------------------------------------------------- |
| `pending`   | The delivery is queued and will be attempted shortly |
| `success`   | Your endpoint responded with `2xx`                   |
| `failed`    | The most recent attempt failed; a retry is scheduled |
| `exhausted` | All retry attempts have been used up                 |

### Manual retries

If a delivery reaches the `failed` or `exhausted` state, you can trigger a manual retry from the Developer Portal:

1. Go to **Webhooks** and click on the endpoint.
2. In the **Delivery History** table, find the failed delivery.
3. Click **Retry** to re-attempt delivery immediately.

This resets the attempt counter and starts a fresh delivery cycle.

***

## Managing Endpoints

### Disabling an endpoint

You can temporarily disable an endpoint from the Webhooks page by clicking **Disable**. While disabled, Friday will not deliver any events to that URL. Events that occur while the endpoint is disabled are not queued — they are simply not sent to that endpoint.

Click **Enable** to resume receiving events.

### Updating an endpoint

You can update the URL or description of an existing endpoint from the Developer Portal. The signing secret remains unchanged when you update other fields.

### Deleting an endpoint

Deleting an endpoint permanently removes it and all associated delivery history. This cannot be undone. If you need to change your signing secret, delete the endpoint and create a new one.

***

## Event Types

Friday emits events across multiple resource types. Every active endpoint for the company receives all events — there is no per-event filtering.

### Employee events

| Event                  | Trigger                                          |
| ---------------------- | ------------------------------------------------ |
| `employee.created`     | A new employee is added to the company           |
| `employee.updated`     | An employee's information is modified            |
| `employee.deactivated` | An employee is dismissed or deactivated          |
| `employee.reactivated` | A previously deactivated employee is reactivated |

### Time record events

| Event                 | Trigger                                     |
| --------------------- | ------------------------------------------- |
| `time_record.created` | A clock-in or manual time record is created |
| `time_record.updated` | A time record is clocked out or edited      |
| `time_record.deleted` | A time record is deleted                    |

### PTO request events

| Event                 | Trigger                                                |
| --------------------- | ------------------------------------------------------ |
| `pto_request.created` | A PTO / time-off request is submitted                  |
| `pto_request.updated` | A PTO request is approved, denied, canceled, or edited |

### Company events

| Event             | Trigger                                    |
| ----------------- | ------------------------------------------ |
| `company.updated` | Company settings or information is changed |

### Customers and projects

In Friday, a **customer** is a job or job site; **projects** belong to a customer and let you track time at a finer grain. The API exposes them as separate REST resources (`/customers` and `/projects`), and webhooks emit **separate event types** for each (`customer.*` vs `project.*`). Subscribe once per company endpoint; you receive both families.

#### Customer events

| Event              | Trigger                                |
| ------------------ | -------------------------------------- |
| `customer.created` | A customer / job site is created       |
| `customer.updated` | A customer's name or status is changed |
| `customer.deleted` | A customer is removed                  |

#### Project events

| Event             | Trigger                               |
| ----------------- | ------------------------------------- |
| `project.created` | A project is created                  |
| `project.updated` | A project's name or status is changed |
| `project.deleted` | A project is removed                  |

### Department events

| Event                | Trigger                        |
| -------------------- | ------------------------------ |
| `department.created` | A department is created        |
| `department.updated` | A department's name is changed |
| `department.deleted` | A department is removed        |

### Pay schedule group events

| Event                        | Trigger                                                                          |
| ---------------------------- | -------------------------------------------------------------------------------- |
| `pay_schedule_group.created` | A pay schedule group is created                                                  |
| `pay_schedule_group.updated` | A pay schedule group's name, default status, or employee assignments are changed |

### Workplace events

| Event               | Trigger                                    |
| ------------------- | ------------------------------------------ |
| `workplace.created` | A new workplace location is added          |
| `workplace.updated` | A workplace's address or status is changed |

### Payroll events (payroll plan required)

| Event                | Trigger                                       |
| -------------------- | --------------------------------------------- |
| `payroll.created`    | A new payroll draft is created                |
| `payroll.updated`    | A draft payroll is modified                   |
| `payroll.approved`   | A payroll is approved for processing          |
| `payroll.processing` | Payroll processing has started                |
| `payroll.paid`       | Payroll has been completed and employees paid |
| `payroll.failed`     | Payroll processing encountered an error       |
| `payroll.reopened`   | An approved payroll is reverted to draft      |
| `payroll.deleted`    | A payroll is removed                          |
| `payroll.skipped`    | A pay period is skipped                       |

***

## Common Pitfalls

* **Lost signing secret** — The secret is only shown once when you create the endpoint. If you lose it, delete the endpoint and create a new one. There is no way to retrieve it.
* **Comparing re-serialized body** — Always verify the signature against the raw request body bytes, not a parsed-and-re-serialized version. JSON serialization is not guaranteed to preserve key order or whitespace.
* **Slow handler blocking acknowledgment** — If your processing takes more than 10 seconds, Friday treats it as a timeout and retries. Acknowledge the webhook with `200` immediately and process the event asynchronously.
* **Not checking for duplicate events** — Retries can result in the same event being delivered more than once. Use the `event_id` to deduplicate.
* **Ignoring the timestamp** — Without timestamp validation, an attacker could replay a captured webhook request. Reject requests older than 5 minutes.
* **Using HTTP instead of HTTPS** — Endpoint URLs must use `https://`. Friday rejects plain HTTP URLs at registration time.
* **Endpoint disabled during an outage** — Events that occur while an endpoint is disabled are not queued or backfilled. You'll need to use the API to fetch any data you missed.

***

## Quick Reference

| Action                  | Where                                                       | How                                                          |
| ----------------------- | ----------------------------------------------------------- | ------------------------------------------------------------ |
| Register an endpoint    | Developer Portal → Webhooks                                 | Click "Register Endpoint", select company, enter HTTPS URL   |
| Copy signing secret     | Developer Portal → Webhooks                                 | Shown once after creation — copy immediately                 |
| View delivery history   | Developer Portal → Webhooks → (click endpoint)              | Paginated table of all deliveries with status and event type |
| Retry a failed delivery | Developer Portal → Webhooks → (endpoint) → Delivery History | Click "Retry" on any `failed` or `exhausted` delivery        |
| Disable/enable endpoint | Developer Portal → Webhooks                                 | Click "Disable" or "Enable" on any endpoint                  |
| Delete endpoint         | Developer Portal → Webhooks                                 | Click "Delete" and confirm                                   |

***

## What's Next

* **Explore the API** — Visit the [API documentation](https://node.friday-staging.com/partner/docs) for the full endpoint reference, including the response schemas that match webhook `data` payloads.
* **Process payroll** — If the company is on a payroll plan, see the [Payroll Processing guide](./payroll-processing) for a step-by-step walkthrough.
* **Test locally** — Use a tool like [ngrok](https://ngrok.com) to expose a local server over HTTPS during development, then register that URL as your webhook endpoint.