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
- Go to Settings > Webhooks in your Frihet account
- Click Create webhook
- Enter a descriptive name, the destination URL, and select the events you want to receive
- Optionally, define a secret for HMAC verification (recommended)
- Save the webhook
URL requirements:
- Must use HTTPS (HTTP is allowed only for
localhostand127.0.0.1during 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
| 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
| Event | Description |
|---|---|
expense.created | A new expense has been logged |
expense.updated | An existing expense has been modified |
Quotes
| 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 |
quote.rejected | A client has rejected a quote |
Clients
| Event | Description |
|---|---|
client.created | A new client has been added |
client.updated | A client's details have been modified |
Products
| 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:
{
"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:
- Take the raw request body
- Compute the HMAC-SHA256 using the secret as the key
- Compare the result with the value in the
X-Frihet-Signatureheader (without thesha256=prefix)
Node.js example
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
import hmac
import hashlib
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:
{
"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.
Troubleshooting
Not receiving webhooks
- Verify the webhook URL is correct and publicly accessible
- Check that the webhook is active in the dashboard
- Review delivery logs for errors
- If you use a firewall, ensure inbound POST requests from Google Cloud (
us-central1) are allowed
Signatures not matching
- Verify you're using the raw request body (before parsing the JSON)
- Confirm the secret in your code matches the one configured in the Frihet dashboard
- Do not modify or reformat the body before verifying the signature
- Check that your framework isn't automatically parsing the body before you can access the raw version
Retries not arriving
- Retries are processed every 5 minutes. If your endpoint was briefly down, the retry window may have already closed.
- Check the delivery history to confirm the status of each attempt
- 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.