Webhooks
A webhook is an automatic message Bokapsys sends to a web address you choose whenever something happens, so another system (your HR tool, a spreadsheet automation, your own software) can react straight away instead of checking Bokapsys on a timer.
Webhooks cover payroll and tax events. They are not part of the standard Bokapsys app and don’t appear in the sidebar. If you need them, ask Bokapsys through Contact us. Where webhooks are set up for you, you manage them on the Webhooks page under Account.
- 1Something happens in payroll or tax
- 2Bokapsys signs a message
- 3Bokapsys sends it to your address
- 4Your system checks the signature
- 5Your system acts on it
This page has two halves: the first is for whoever sets the webhook up in Bokapsys, the second (“For developers”) is for whoever builds the system that receives it.
Before you start
Section titled “Before you start”- Webhooks must be set up for you. Ask Bokapsys through Contact us in the sidebar.
- Your role must allow it. Viewing webhooks and adding an endpoint each need their own permission. By default the super_admin, admin and cfo roles have both. See Team and roles.
- Have the receiving address ready. You need a web address (URL) on the public internet that accepts
POSTrequests, for examplehttps://hooks.example.com/bokapsys. Your developer or automation tool gives you this. - Decide which events you want (see the list below), or leave the filter empty to receive all of them.
- Have somewhere safe to store a secret. Bokapsys shows the endpoint’s signing secret once, the moment you save. Have your password manager or your developer ready to receive it.
Add an endpoint
Section titled “Add an endpoint”- In the sidebar, open Account and click Webhooks. You see two cards: Endpoints and Recent deliveries.
- Click Add endpoint at the top right. The Add webhook endpoint window opens.
- Fill in the fields described below.
- Click Save.
- A message appears: Webhook created, followed by Signing secret: whsec_… - store it now, won’t be shown again. Copy the whole secret, starting with
whsec_, before the message disappears, and store it safely. - The endpoint appears in the Endpoints table.
| Field | What it means | Required? | Example |
|---|---|---|---|
| URL | The address Bokapsys sends each message to. Must start with https:// or http://. Use https:// so the message is encrypted on its way. |
Yes | https://hooks.example.com/bokapsys |
| Description | A note for yourself about what this endpoint is for. | No | Sync payroll to HR system |
| Event filters (comma-separated, empty = all events) | The event names you want, separated by commas. Leave it empty to receive every event. Names must be typed exactly as listed below. | No | payroll.run.posted, tax.filing.generated |
The Endpoints table then shows:
| Column | What it shows |
|---|---|
| URL | The address. |
| Description | Your note. |
| Filters | One tag per event you chose. No tags means all events. |
| Secret | The first few characters of the signing secret followed by …, so you can tell secrets apart. The full secret is never shown again. |
| Active | Yes while Bokapsys is sending to it. |
How to check it worked: the endpoint is listed with Active: Yes. Then do something that triggers one of its events (for example, create a draft pay run for payroll.run.created) and wait about a minute. A row appears in Recent deliveries for that event. If its Status is delivered and HTTP is a number in the 200s, your system received it.
Addresses Bokapsys refuses
Section titled “Addresses Bokapsys refuses”To protect your network and ours, Bokapsys only sends to addresses on the public internet. When you save, it looks the address up, and it refuses the endpoint (and later refuses to send) if the address:
- is
localhostor ends in.localhostor.internal; - is, or looks up to, a private or internal address, such as
10.x.x.x,172.16–31.x.x,192.168.x.x,127.x.x.xor a link-local address; - cannot be looked up at all.
| Message | What to do |
|---|---|
| url required (http/https) | The URL is empty or doesn’t start with http:// or https://. Add the scheme. |
| Webhook URL is not a valid URL. | Check for spaces or missing parts. Copy the address from your developer rather than retyping it. |
| Webhook URL host “…” is not reachable from Bokapsys. / …is a private or link-local address. / …resolves to a private or link-local address. | The address is on a private or internal network. Use a public address; if your system is internal, put a public endpoint in front of it. |
| Webhook URL host “…” could not be resolved. | The domain name doesn’t exist or is misspelled. Check it. |
Common mistakes
Section titled “Common mistakes”- Not copying the secret. It is shown once. If you lose it, add a new endpoint to get a new secret.
- A typo in an event filter. Filters must match the event name exactly, so
payroll.run.Postedorpayroll.runs.postednever matches anything, and nothing is sent. Copy names from the table below. - Testing with a local address.
http://localhost:3000is refused. Use a public test address.
Events you can choose
Section titled “Events you can choose”| Event name | When it is sent |
|---|---|
payroll.run.created |
A pay run is created. |
payroll.run.scheduled |
A pay schedule automatically creates its next draft pay run. |
payroll.run.calculated |
A pay run is calculated. |
payroll.run.approved |
A pay run is approved. |
payroll.run.posted |
A pay run is posted to your books. |
payroll.run.paid |
A pay run is marked as paid. |
payroll.run.reversed |
A posted pay run is reversed. |
payroll.employee.created |
An employee is added. |
payroll.employee.updated |
An employee’s details change. |
payroll.employee.terminated |
An employee is terminated. |
payroll.contractor.created |
A contractor is added. |
payroll.contractor.updated |
A contractor’s details change. |
payroll.benefit_enrollment.updated |
A benefit enrollment changes. |
payroll.anomaly.detected |
A workflow’s payroll anomaly check finds something unusual. |
tax.filing.generated |
A tax filing is generated. |
tax.filing.regenerated |
A tax filing is regenerated. |
tax.filing.due_soon |
A tax filing that is still open is coming up to its due date. |
tax.payment.scheduled |
A tax payment is scheduled. |
tax.payment.released |
A tax payment is recorded as paid. |
Delivery history and retries
Section titled “Delivery history and retries”The Recent deliveries card shows the latest 100 delivery attempts across all your endpoints, newest first.
| Column | What it shows |
|---|---|
| Event | The event name. |
| URL | Which endpoint it went to. |
| Attempt | How many times Bokapsys has tried so far. |
| Status | See below. |
| HTTP | The response code your system sent back, if any. |
| When | The date the delivery was queued. |
| Status | What it means |
|---|---|
| pending | Queued; Bokapsys hasn’t tried yet. |
| retrying | The last try failed; Bokapsys will try again later. |
| delivered | Your system answered with a success code (200–299). |
| dead_letter | Bokapsys tried the maximum number of times and gave up. |
| failed | The endpoint was switched off before the message could be sent. |
How retries work
Section titled “How retries work”- Bokapsys checks for new events about every 30 seconds, so a message normally arrives within a minute.
- A delivery succeeds when your system replies with a status code from 200 to 299 within 10 seconds. Anything else (an error code, no answer, or taking longer than 10 seconds) counts as a failure.
- After a failure, Bokapsys tries again with growing gaps, starting at under a minute and stretching to a day, up to 8 attempts in total. After the eighth failed attempt the delivery is marked dead_letter and is not tried again.
- A retry sends exactly the same message, with the same signature, as the first try.
If deliveries keep failing, look at the HTTP column. 401 or 403 usually means your system is rejecting the signature or needs a login; 404 means the path is wrong; 500 means your system crashed handling it; a blank HTTP code means Bokapsys could not reach the address at all or your system took longer than 10 seconds.
For developers: what a delivery looks like
Section titled “For developers: what a delivery looks like”Each delivery is an HTTP POST to your URL with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Bokapsys-Signature |
The signature, as 64 lowercase hexadecimal characters. |
User-Agent |
Bokapsys-Webhooks/1.0 |
The body is a JSON object with these fields (they may appear in any order):
| Field | Meaning |
|---|---|
event_id |
A unique id for the event. The same event keeps the same id across retries, so use it to ignore duplicates. |
event_type |
The event name, for example payroll.run.posted. |
tenant_id |
Your workspace’s id. |
occurred_at |
When the event happened (ISO 8601 timestamp). |
payload |
Details of the event. The fields depend on the event type. Money amounts are whole cents in fields ending _minor_units, sent as text ("1250000" is $12,500.00). |
signature |
An internal value. Do not use it to verify the message; use the header. |
Example: a pay run is posted.
POST /bokapsys HTTP/1.1Host: hooks.example.comContent-Type: application/jsonX-Bokapsys-Signature: 5f2c9a…(64 hex characters)User-Agent: Bokapsys-Webhooks/1.0
{"event_id":"3b1f7c2e-8d4a-4b8e-9f61-2a7d5c0e9b14","event_type":"payroll.run.posted","tenant_id":"7a0c…","occurred_at":"2026-10-15T14:02:11.482Z","payload":{"run_id":"…","batch_id":"…","accounting_entry_count":12,"gross_minor_units":"1250000"},"signature":"…"}For developers: verify the signature
Section titled “For developers: verify the signature”Every message is signed so you can prove it came from Bokapsys and wasn’t changed on the way. The scheme is HMAC-SHA256:
- Key: the endpoint’s full signing secret, exactly as shown when you created it, including the
whsec_prefix. - Message: the raw request body, byte for byte, exactly as received.
- Signature: the HMAC-SHA256 of the message with the key, written as lowercase hex. This must equal the
X-Bokapsys-Signatureheader.
Two rules make verification work:
- Sign the raw body, not re-encoded JSON. If your framework parses the JSON and you turn it back into text, spacing or key order can change and the signature will not match. Read the raw bytes first.
- Compare in constant time (for example
crypto.timingSafeEqualorhmac.compare_digest), and reject the message with a401if the signatures differ.
const crypto = require('crypto');const express = require('express');const app = express();const SECRET = process.env.BOKAPSYS_WEBHOOK_SECRET;
app.post('/bokapsys', express.raw({ type: 'application/json' }), (req, res) => { const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex'); const received = String(req.get('X-Bokapsys-Signature') || ''); const ok = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)); if (!ok) return res.status(401).end(); const event = JSON.parse(req.body.toString('utf8')); res.status(200).end(); handle(event);});import hashlib, hmac, json, osfrom flask import Flask, request, abort
app = Flask(__name__)SECRET = os.environ["BOKAPSYS_WEBHOOK_SECRET"].encode()
@app.post("/bokapsys")def bokapsys(): raw = request.get_data() expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() received = request.headers.get("X-Bokapsys-Signature", "") if not hmac.compare_digest(received, expected): abort(401) event = json.loads(raw) handle(event) return "", 200Good practice for your receiver:
- Reply quickly. Return
200as soon as the signature checks out, then do slow work afterwards. A reply that takes longer than 10 seconds counts as a failure and is retried. - Expect duplicates. A retry can arrive after your system has already handled the first copy (for example, if your
200was lost). Store eachevent_idyou have processed and skip repeats. - Don’t rely on order. Retries mean events can arrive out of order. Use
occurred_at, or fetch the latest state, rather than assuming the last message you received is the newest. - Keep the secret secret. Store it like a password, never in front-end code or a public repository.