# Webhooks

Frihet webhooks let you receive real-time HTTP notifications when events occur in your account. Instead of polling the API, configure an endpoint URL and Frihet sends a POST request with event data the moment it happens.

## Setup

You can manage webhooks from the Frihet dashboard (**Settings > Webhooks**) or programmatically through the REST API.

### From the dashboard

1. Go to **Settings > Webhooks** in your Frihet account
2. Click **Create webhook**
3. Enter a descriptive name, the destination URL, and select the events you want to receive
4. Optionally, define a **secret** for HMAC verification (recommended)
5. Save the webhook

### Webhook REST API

Manage webhooks programmatically with the full CRUD endpoints.

#### List webhooks

```
GET /v1/webhooks
```

```bash
curl https://api.frihet.io/v1/webhooks \
  -H "X-API-Key: fri_your-key-here"
```

**Response (200):**

```json
{
  "data": [
    {
      "id": "wh_abc123",
      "url": "https://my-app.com/hook",
      "events": ["invoice.paid", "invoice.created"],
      "active": true,
      "createdAt": "2026-02-01T10:00:00.000Z"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
```

#### Get webhook

```
GET /v1/webhooks/:id
```

```bash
curl https://api.frihet.io/v1/webhooks/wh_abc123 \
  -H "X-API-Key: fri_your-key-here"
```

#### Create webhook

```
POST /v1/webhooks
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | Destination URL (HTTPS required in production) |
| `events` | string[] | Yes | List of event types to receive |
| `secret` | string | No | Secret for HMAC-SHA256 signing (recommended) |
| `active` | boolean | No | Webhook status (default: `true`) |

```bash
curl -X POST https://api.frihet.io/v1/webhooks \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-app.com/hook",
    "events": ["invoice.paid", "expense.created"],
    "secret": "my-hmac-secret"
  }'
```

**Response (201):**

```json
{
  "id": "wh_def456",
  "url": "https://my-app.com/hook",
  "events": ["invoice.paid", "expense.created"],
  "active": true,
  "createdAt": "2026-03-18T10:00:00.000Z"
}
```

#### Update webhook

```
PATCH /v1/webhooks/:id
```

You only need to send the fields you want to modify.

```bash
curl -X PATCH https://api.frihet.io/v1/webhooks/wh_def456 \
  -H "X-API-Key: fri_your-key-here" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["invoice.paid", "invoice.created", "expense.created"],
    "active": false
  }'
```

**Response (200):** Updated webhook object.

#### Delete webhook

```
DELETE /v1/webhooks/:id
```

```bash
curl -X DELETE https://api.frihet.io/v1/webhooks/wh_def456 \
  -H "X-API-Key: fri_your-key-here"
```

**Response:** `204 No Content`

---

**URL requirements:**
- Must use HTTPS (HTTP is allowed only for `localhost` and `127.0.0.1` during development)
- Private IPs (10.x, 172.16-31.x, 192.168.x) are not allowed to prevent SSRF attacks
- Must respond with a 2xx status code within 30 seconds

**Limits:**
- Maximum **20 webhooks** per account
- Maximum payload size of **100 KB** per delivery

---

## Event types

Frihet emits 14 event types, grouped by resource.

### Invoices (4 events)

| Event | Description |
|-------|-------------|
| `invoice.created` | A new invoice has been created |
| `invoice.updated` | An existing invoice has been modified |
| `invoice.paid` | An invoice has been marked as paid |
| `invoice.overdue` | An invoice has passed its due date |

### Expenses (2 events)

| Event | Description |
|-------|-------------|
| `expense.created` | A new expense has been logged |
| `expense.updated` | An existing expense has been modified |

### Quotes (4 events)

| Event | Description |
|-------|-------------|
| `quote.created` | A new quote has been created |
| `quote.updated` | An existing quote has been modified |
| `quote.accepted` | A client has accepted a quote (status changed to `accepted`) |
| `quote.rejected` | A client has rejected a quote (status changed to `rejected`) |

### Clients (2 events)

| Event | Description |
|-------|-------------|
| `client.created` | A new client has been added |
| `client.updated` | A client's details have been modified |

### Products (2 events)

| Event | Description |
|-------|-------------|
| `product.created` | A new product or service has been created |
| `product.updated` | A product or service has been modified |

---

## Payload structure

Each webhook delivery is a `POST` request with a JSON body. The structure is the same for all event types:

```json
{
  "event": "invoice.paid",
  "timestamp": "2026-02-12T14:30:00.000Z",
  "data": {
    "id": "inv_abc123",
    "clientName": "Acme SL",
    "items": [
      { "description": "Consulting", "quantity": 10, "unitPrice": 75 }
    ],
    "total": 750,
    "status": "paid",
    "paidAt": "2026-02-12T14:29:58.000Z",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-02-12T14:30:00.000Z"
  }
}
```

The `data` field contains the full state of the resource at the time of the event.

---

## Delivery headers

Every webhook request includes the following headers:

| Header | Description | Example |
|--------|-------------|---------|
| `Content-Type` | Content type | `application/json` |
| `X-Frihet-Event` | Event type | `invoice.paid` |
| `X-Frihet-Delivery-Id` | Unique delivery identifier | `d4e5f6a7b8c9` |
| `X-Frihet-Timestamp` | ISO 8601 timestamp | `2026-02-12T14:30:00.000Z` |
| `X-Frihet-Signature` | HMAC-SHA256 signature of the payload | `sha256=a1b2c3d4e5f6...` |

The `X-Frihet-Signature` header is only included if you configured a secret on the webhook.

---

## Signature verification

If you configure a secret when creating the webhook, Frihet signs every payload with HMAC-SHA256. The verification process:

1. Take the raw request body
2. Compute the HMAC-SHA256 using the secret as the key
3. Compare the result with the value in the `X-Frihet-Signature` header (without the `sha256=` prefix)

### With the SDK (recommended)

```typescript

app.post('/webhook/frihet', express.raw({ type: 'application/json' }), (req, res) => {
  const isValid = Webhooks.verifySignature(
    req.body,
    req.headers['x-frihet-signature'],
    process.env.FRIHET_WEBHOOK_SECRET,
  );

  if (!isValid) return res.status(401).send('Invalid signature');

  const event = JSON.parse(req.body.toString());
  console.log(req.headers['x-frihet-event'], event.data);
  res.sendStatus(200);
});
```

### Manual Node.js example

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

function verifyWebhookSignature(rawBody, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const receivedSignature = signature.replace('sha256=', '');

  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, 'hex'),
    Buffer.from(receivedSignature, 'hex')
  );
}

// Usage in an Express server
app.post('/webhook/frihet', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-frihet-signature'];
  const secret = process.env.FRIHET_WEBHOOK_SECRET;

  if (!signature || !verifyWebhookSignature(req.body.toString(), signature, secret)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  const eventType = req.headers['x-frihet-event'];

  console.log(`Event received: ${eventType}`, event.data);

  // Handle the event by type
  switch (eventType) {
    case 'invoice.paid':
      // Update your accounting system
      break;
    case 'expense.created':
      // Notify the finance team
      break;
  }

  res.status(200).send('OK');
});
```

### Python example

```python
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = 'your-secret-here'

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    received = signature.replace('sha256=', '')

    return hmac.compare_digest(expected, received)

@app.route('/webhook/frihet', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Frihet-Signature', '')
    if not verify_signature(request.data, signature, WEBHOOK_SECRET):
        abort(401)

    event = request.get_json()
    event_type = request.headers.get('X-Frihet-Event')

    print(f'Event received: {event_type}')

    if event_type == 'invoice.paid':
        # Update your accounting system
        pass
    elif event_type == 'quote.accepted':
        # Convert quote to invoice
        pass

    return 'OK', 200
```

**Important:** Always use constant-time comparison (`timingSafeEqual` in Node.js, `compare_digest` in Python) to prevent timing attacks.

---

## Retry policy

If a webhook delivery fails (non-2xx response or timeout), Frihet retries automatically with **exponential backoff**:

| Attempt | Delay | Cumulative time |
|---------|-------|-----------------|
| 1 (initial) | immediate | 0s |
| 2 (first retry) | 2 seconds | 2s |
| 3 (second retry) | 4 seconds | 6s |

- Maximum **3 attempts** per delivery (1 initial + 2 retries)
- Maximum **30 seconds** delay between retries
- **30-second timeout** per request
- If all 3 attempts fail, the delivery is marked `failed`

Retries are processed by a job that runs every 5 minutes, ensuring reliability even if the main process restarts.

You can check the **delivery history** for each webhook from the Frihet dashboard, including the response code, response body (first 1000 characters), and errors for each attempt.

---

## Testing

From the Frihet dashboard, you can send a **test event** to any configured webhook. The test payload looks like:

```json
{
  "event": "webhook.test",
  "timestamp": "2026-02-12T14:30:00.000Z",
  "data": {
    "message": "This is a test webhook from Frihet ERP"
  }
}
```

This lets you verify that your endpoint is reachable, that signature validation works, and that your system handles events without errors.

---

## Best practices

### Respond fast

Your endpoint should respond with a `200` status code as quickly as possible. If you need to do heavy processing (send emails, update external databases, etc.), accept the event and process it asynchronously in a job queue.

### Handle idempotency

It's possible for the same event to be delivered more than once (for example, if your server timed out but still processed the event). Use the `X-Frihet-Delivery-Id` header as an idempotency key to avoid duplicates.

### Always verify the signature

Never trust a webhook without verifying the `X-Frihet-Signature` header. Anyone with access to your URL could send fake payloads.

### Use HTTPS

In production, your endpoint must be protected with HTTPS. Frihet rejects HTTP URLs (except in local development).

### Monitor failures

Check delivery logs in the Frihet dashboard periodically. If you see recurring failed deliveries, verify that your endpoint is available and responds in under 30 seconds.

### Filter events

Subscribe only to the events you need. Each webhook can listen to one or several event types. Processing fewer unnecessary events reduces load on your server.

---

## Debugging deliveries

Frihet logs the result of every webhook delivery. You can inspect them from the dashboard:

1. Go to **Settings > Developers > Webhooks**
2. Click on the webhook you want to inspect
3. Open the **Deliveries** tab

Each entry shows:

- **Response code** returned by your endpoint
- **Response time** in milliseconds
- **Response body** (first 1000 characters)
- **Date and time** of each attempt (including retries)
- **Final status**: `delivered`, `retrying`, or `failed`

### Common issues

| Symptom | Likely cause | Solution |
|---------|--------------|----------|
| All deliveries fail with timeout | Endpoint takes more than 30s to respond | Accept the event with an immediate `200` and process asynchronously |
| SSL/TLS error | Expired certificate or incomplete chain | Renew the certificate and verify the chain with `openssl s_client` |
| Systematic 403 | Firewall blocking inbound POST requests | Allow traffic from Google Cloud IPs (`us-central1`) |
| 502/503 | Server down or under maintenance | Check your server logs and verify the process is running |

### Test button

On each webhook's detail screen, the **Send test** button sends a synthetic `webhook.test` event to your URL. Use this button to verify:

- That the URL is reachable from the internet
- That the HMAC signature validates correctly
- That your server responds with a `2xx` code

The test result appears immediately in the delivery log.

---

## Troubleshooting

### Not receiving webhooks

1. Verify the webhook URL is correct and publicly accessible
2. Check that the webhook is **active** in the dashboard
3. Review delivery logs for errors
4. If you use a firewall, ensure inbound POST requests from Google Cloud (`us-central1`) are allowed

### Signatures not matching

1. Verify you're using the raw request body (before parsing the JSON)
2. Confirm the secret in your code matches the one configured in the Frihet dashboard
3. Do not modify or reformat the body before verifying the signature
4. Check that your framework isn't automatically parsing the body before you can access the raw version

### Retries not arriving

1. Retries are processed every 5 minutes. If your endpoint was briefly down, the retry attempts may have already been exhausted
2. Check the delivery history to confirm the status of each attempt
3. If all 3 attempts failed, the delivery will not be retried automatically

### Payload too large

If the resource associated with the event is very large (many line items in an invoice, extensive fields), the payload may exceed the 100 KB limit and the delivery will be rejected. Simplify the resource data or contact support if you need a higher limit.
