Skip to content
BokapsysDocs
Sign in

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.

  1. 1Something happens in payroll or tax
  2. 2Bokapsys signs a message
  3. 3Bokapsys sends it to your address
  4. 4Your system checks the signature
  5. 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.

  • 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 POST requests, for example https://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.
  1. In the sidebar, open Account and click Webhooks. You see two cards: Endpoints and Recent deliveries.
  2. Click Add endpoint at the top right. The Add webhook endpoint window opens.
  3. Fill in the fields described below.
  4. Click Save.
  5. 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.
  6. 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.

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 localhost or ends in .localhost or .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.x or 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.
  • 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.Posted or payroll.runs.posted never matches anything, and nothing is sent. Copy names from the table below.
  • Testing with a local address. http://localhost:3000 is refused. Use a public test address.
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.

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.
  • 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.1
Host: hooks.example.com
Content-Type: application/json
X-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":"…"}

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-Signature header.

Two rules make verification work:

  1. 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.
  2. Compare in constant time (for example crypto.timingSafeEqual or hmac.compare_digest), and reject the message with a 401 if 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);
});

Good practice for your receiver:

  • Reply quickly. Return 200 as 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 200 was lost). Store each event_id you 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.