Skip to main content

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

  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

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

EventDescription
invoice.createdA new invoice has been created
invoice.updatedAn existing invoice has been modified
invoice.paidAn invoice has been marked as paid
invoice.overdueAn invoice has passed its due date

Expenses

EventDescription
expense.createdA new expense has been logged
expense.updatedAn existing expense has been modified

Quotes

EventDescription
quote.createdA new quote has been created
quote.updatedAn existing quote has been modified
quote.acceptedA client has accepted a quote
quote.rejectedA client has rejected a quote

Clients

EventDescription
client.createdA new client has been added
client.updatedA client's details have been modified

Products

EventDescription
product.createdA new product or service has been created
product.updatedA 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:

HeaderDescriptionExample
Content-TypeContent typeapplication/json
X-Frihet-EventEvent typeinvoice.paid
X-Frihet-Delivery-IdUnique delivery identifierd4e5f6a7b8c9
X-Frihet-TimestampISO 8601 timestamp2026-02-12T14:30:00.000Z
X-Frihet-SignatureHMAC-SHA256 signature of the payloadsha256=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)

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:

AttemptDelayCumulative time
1 (initial)immediate0s
2 (first retry)2 seconds2s
3 (second retry)4 seconds6s
  • 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

  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 window may have already closed.
  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.