On this page and tools
Automation
Webhooks
Webhooks tell your server when something happens in your Bynefit space — a new member joins, a form is submitted, a donation arrives — without you having to poll for it.
The gist
- You tell Bynefit what URL to call and which events you care about.
- When an event happens, Bynefit sends an HTTP
POSTto that URL with a JSON payload. - You respond with
200to say "got it." - If your server has trouble, Bynefit will retry for a while before giving up.
Who can use it: a Space admin sets up endpoints. Your server receives them. It is also how Make, n8n and Zapier catch Bynefit events. See No-code and automation tools.
Subscribe
Space admins can manage webhooks at
/dash/integrations/webhooks.
Give the endpoint a label (up to 160 characters), paste your delivery URL, optionally list the
events you want (leave it empty for all of them), and click
Add endpoint. You'll be given a signing secret
— it starts with whsec_, and you copy it now because it's only shown once.
Delivery URLs must be public web addresses (use https); an address that points at a private network is refused.
Delivery payload
POST /your/webhook/url HTTP/1.1
Content-Type: application/json
User-Agent: Bynli-Webhooks/1.0
X-Bynefit-Event: form.submitted
X-Bynefit-Event-Id: evt_9c2e5a1f…
X-Bynefit-Delivery: 7b6c1d…
X-Bynefit-Timestamp: 1776800702
X-Bynefit-Signature: sha256=…
{
"id": "evt_9c2e5a1f…",
"type": "form.submitted",
"team_id": 42,
"created_at": "2026-04-21T19:45:02+00:00",
"actor": { "id": null },
"subject": { "type": "form_submission", "id": "sub_4d1e…" },
"data": {
"form_id": "frm_AbC123xYz",
"form_name": "Contact us",
"form_type": "standard"
}
}
| Field | Meaning |
|---|---|
id | Unique, stable id of the event. The same event keeps the same id on every retry. |
type | The event name, from the list below. |
team_id | The space the event happened in. |
created_at | When it happened, ISO-8601. |
actor | Who triggered it: { "id": … }, with id null for system events and for people who are not signed in, such as someone filling in a form on a hosted site. |
subject | The record the event is about: type (for example contact, form_submission, task, membership, payment, call, feed_post) and an opaque id. Either can be null when an event has no single record. |
data | Event-specific details, listed below. May be null. |
The payload tells you that something happened and which record it is about. It does not carry personal details such as form answers, email addresses or phone numbers. To get the answers to a form, read the submission from your server with a Space key and the forms permission. See Authentication.
Headers
| Header | Value |
|---|---|
X-Bynefit-Event | The event name, same as type. |
X-Bynefit-Event-Id | The event's id. Use this to de-duplicate. |
X-Bynefit-Delivery | A unique id for this delivery attempt. It changes on every retry. |
X-Bynefit-Timestamp | When the request was signed, in Unix seconds. |
X-Bynefit-Signature | sha256=<hex>, described below. |
X-Bynefit-* is the name to read. Every delivery also carries the same value under the older
X-Bynli-* name (for example X-Bynli-Signature), so receivers written against it keep working.
The header name is not part of what is signed. If you accept both names and a request carries both with different values, reject it.
Available events
In the event filter you can list exact names or a prefix wildcard such
as task.*. Separate several with commas.
| Event | When it fires | What data holds |
|---|---|---|
contact.created | A contact is added to your space. | source (for example manual) |
contact.updated | A contact's details change. | fields, the names of what changed |
task.created | A task is created, including follow-up tasks Bynefit opens for your team when a client submits a request or a form arrives. | priority, has_due_date, assigned. Follow-up tasks Bynefit opens also carry source (such as engagement, booking or form_submission) and kind (such as order_submitted, client_replied, client_paid or form_submitted). |
task.updated | A task's details change. | The new status, or the fields that changed |
task.assigned | A task is assigned or reassigned. | from and to |
task.completed | A task is marked complete. | Empty |
task.deleted | A task is deleted. | Empty |
form.submitted | A form is submitted, from the dashboard or from a hosted site. | form_id, form_name, form_type. From a hosted site also source (team_site), site_slug and site_id. |
member.joined | A new person joins your space. | via and role |
member.removed | A person is removed from your space. | role |
donation.received | A donation is received. | amount, total, currency, tx_ref |
call.completed | A phone-bank call is completed. | direction, call_type, duration_sec, status |
feed.posted | A post is published to your space's feed. | tag and has_media |
integration.connected, integration.disconnected | A connected service is linked or unlinked. | connector, the service's name |
There is no event for engagement or Business Setup changes, for partner events, or for site visits.
A new engagement request or a client reply shows up as a task.created for your team, without the engagement in it.
Verifying a delivery
Every delivery is signed with your signing secret so you can confirm it
came from Bynefit and wasn't modified in transit. The signature is in the
X-Bynefit-Signature header as sha256=<hex>,
where <hex> is the HMAC-SHA256, keyed with your
signing secret, of the timestamp, a literal dot, and the raw request
body — {timestamp}.{body}, using the value of
X-Bynefit-Timestamp. Compute it over the exact bytes you
received, before any JSON parsing.
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
const pick = (name) => {
const a = headers["x-bynefit-" + name];
const b = headers["x-bynli-" + name];
if (a !== undefined && b !== undefined && a !== b) return "";
return (a !== undefined ? a : b) || "";
};
const ts = pick("timestamp");
if (!/^\d+$/.test(ts || "") || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
const a = Buffer.from(pick("signature"));
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { verify };import hashlib
import hmac
import time
def pick(h: dict, name: str) -> str:
new = h.get("x-bynefit-" + name)
old = h.get("x-bynli-" + name)
if new is not None and old is not None and new != old:
return ""
return (new if new is not None else old) or ""
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
h = {k.lower(): v for k, v in headers.items()}
ts = pick(h, "timestamp")
if not (ts.isascii() and ts.isdigit()) or abs(time.time() - int(ts)) > 300:
return False
expected = "sha256=" + hmac.new(
secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(pick(h, "signature").encode(), expected.encode())function bynefit_header(array $h, string $name): string
{
$new = $h['x-bynefit-' . $name] ?? null;
$old = $h['x-bynli-' . $name] ?? null;
if ($new !== null && $old !== null && $new !== $old) {
return '';
}
return (string) ($new ?? $old ?? '');
}
function bynefit_verify(string $rawBody, array $headers, string $secret): bool
{
$h = array_change_key_case($headers, CASE_LOWER);
$ts = bynefit_header($h, 'timestamp');
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, bynefit_header($h, 'signature'));
}
The samples above also reject any delivery whose
X-Bynefit-Timestamp is more than five minutes away from your
server's clock, which stops an attacker replaying a captured payload
later. Keep your server clock synced. Compare signatures in constant time, as the samples do.
A whole receiver
Read the body as raw bytes before anything parses it, check the signature, skip an event you have already handled, and answer quickly.
The Node and Python versions use the verifier above, saved next to them as verify.js and verify.py.
The in-memory seen set is for the example; use a database or cache that survives a restart.
const express = require("express");
const { verify } = require("./verify");
const app = express();
const seen = new Set();
app.post("/bynefit", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.headers, process.env.BYNEFIT_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
if (seen.has(event.id)) return res.sendStatus(200);
seen.add(event.id);
console.log(event.type, event.subject);
res.sendStatus(200);
});
app.listen(3000);import json
import os
from flask import Flask, request
from verify import verify
app = Flask(__name__)
seen = set()
@app.post("/bynefit")
def bynefit():
raw = request.get_data()
if not verify(raw, dict(request.headers), os.environ["BYNEFIT_WEBHOOK_SECRET"]):
return "", 401
event = json.loads(raw)
if event["id"] in seen:
return "", 200
seen.add(event["id"])
print(event["type"], event["subject"])
return "", 200$raw = file_get_contents('php://input');
$headers = [
'X-Bynefit-Timestamp' => $_SERVER['HTTP_X_BYNEFIT_TIMESTAMP'] ?? null,
'X-Bynefit-Signature' => $_SERVER['HTTP_X_BYNEFIT_SIGNATURE'] ?? null,
'X-Bynli-Timestamp' => $_SERVER['HTTP_X_BYNLI_TIMESTAMP'] ?? null,
'X-Bynli-Signature' => $_SERVER['HTTP_X_BYNLI_SIGNATURE'] ?? null,
];
if (!bynefit_verify($raw, $headers, getenv('BYNEFIT_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
error_log($event['type'] . ' ' . $event['id']);
http_response_code(200);Try your receiver before a real event
Until test events exist (see below), send a delivery to yourself. This signs a made-up form.submitted exactly the way Bynefit does.
Run it against a copy of your receiver, and use a secret you made up, not the real one.
URL="https://your-server.example.com/bynefit"
SECRET="whsec_your_signing_secret"
TS=$(date +%s)
BODY='{"id":"evt_test0001","type":"form.submitted","team_id":42,"created_at":"2026-10-10T14:03:22+00:00","actor":{"id":null},"subject":{"type":"form_submission","id":"sub_test0001"},"data":{"form_id":"frm_test0001","form_name":"Contact us","form_type":"standard"}}'
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST "$URL" \
-H "Content-Type: application/json" \
-H "X-Bynefit-Event: form.submitted" \
-H "X-Bynefit-Event-Id: evt_test0001" \
-H "X-Bynefit-Delivery: local-test-1" \
-H "X-Bynefit-Timestamp: $TS" \
-H "X-Bynefit-Signature: sha256=$SIG" \
-d "$BODY"
Responding
- Return any
2xxstatus quickly. Bynefit waits 10 seconds for an answer and then counts the attempt as failed. - Do slow work in the background — accept the delivery first, then process.
- Return a
4xxor5xxif you can't handle it. - Redirects are not followed. Give the final address.
Retries and back-off
Deliveries go out about a minute after the event happens. When your server returns a non-2xx or times out, Bynefit tries again after about 1 minute, then 5 minutes, then 30 minutes, then 2 hours. That is five attempts in all, over roughly two and a half hours, and then Bynefit gives up on that event for that endpoint.
Separately, an endpoint is switched off after 20 failed deliveries in a row, and the Space's admins are emailed. A successful delivery resets the count. Turn the endpoint back on from /dash/integrations/webhooks once your server is healthy.
X-Bynefit-Event-Id
(the same value as the payload's id, constant across
retries) to de-duplicate on your side.
X-Bynefit-Delivery changes on every attempt, so it is for
logging, not de-duplication.
Send a test event
Available On /dash/integrations/webhooks, choose Send test event on an endpoint. Bynefit posts a sample event to that address straight away and shows you the status code your server answered and how long it took.
- It is signed with the endpoint's secret exactly like a real delivery, so your signature check runs for real.
- The event is the first exact type in the endpoint's filter. A filter with only wildcards gets the first event that matches, so
task.*sendstask.created. With no filter, it sendsform.submitted. Itsdatais the same shape as the real event. - A Space manager or admin can send one.
- It is marked as a test three ways: the id starts with
evt_test_, the body carries"livemode": false, and the request has the headerX-Bynefit-Test: true. Skip those on your side if you do not want test events stored. - It holds no real data, is not added to the endpoint's recent deliveries, and does not change its failure count.
- You can send a few in a row. After that the page asks you to wait a few minutes.
Replay a past delivery
Available On /dash/integrations/webhooks, open Recent deliveries for an endpoint and choose Replay on a row. Bynefit sends that delivery's stored event to the endpoint's address again and shows the status your server answered.
- Only a Space admin can replay, and only deliveries of that Space's own endpoints. The endpoint has to be switched on, and the delivery no more than 30 days old.
- The body is the original event, byte for byte, so
idandX-Bynefit-Event-Idare the same as the first time. De-duplicate on them if you must not process an event twice. - It is signed again with the endpoint's current secret and a fresh
X-Bynefit-Timestamp, so a replay of an old event still passes your timestamp check. - It is marked with the header
X-Bynefit-Replay: true(andX-Bynli-Replay: true).X-Bynefit-Deliveryis new. - It appears in the endpoint's recent deliveries as a new attempt, is recorded in the Space's activity log with who sent it, and does not change the endpoint's failure count.
- You can replay a few in a row. After that the page asks you to wait a few minutes.
Managing endpoints
You can pause, resume, rotate the signing secret, and delete endpoints from /dash/integrations/webhooks. Rotating the signing secret invalidates the previous one immediately, so update your server first or accept a short gap. The page also lists the recent deliveries for each endpoint with the status your server returned.