# 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

1. You tell Bynefit what URL to call and which events you care about.
2. When an event happens, Bynefit sends an HTTP `POST` to that URL with a JSON payload.
3. You respond with `200` to say "got it."
4. 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](https://bynefit.com/docs/no-code).

## Subscribe

Space admins can manage webhooks at [/dash/integrations/webhooks](https://bynefit.com/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.

> **Keep your signing secret private.** Anyone with it can forge delivery payloads. Put it in your server's environment, never in a web page or an app.

## Delivery payload

**POST (your server)**

```http
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](https://bynefit.com/docs/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.

**JavaScript**

```javascript
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 };
```

**Python**

```python
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())
```

**PHP**

```php
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.

**JavaScript**

```javascript
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);
```

**Python**

```python
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
```

**PHP**

```php
$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.

**cURL (bash)**

```bash
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 `2xx` status 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 `4xx` or `5xx` if 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](https://bynefit.com/dash/integrations/webhooks) once your server is healthy.

> **Idempotency.** Because retries exist, the same event can arrive more than once. Use `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](https://bynefit.com/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.*` sends `task.created`. With no filter, it sends `form.submitted`. Its `data` is 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 header `X-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](https://bynefit.com/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 `id` and `X-Bynefit-Event-Id` are 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` (and `X-Bynli-Replay: true`). `X-Bynefit-Delivery` is 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](https://bynefit.com/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.

---

Source: https://bynefit.com/docs/webhooks
Checked against the live API on October 10, 2026.
