Webhooks
bind.co sends your application an HTTPS request when something happens for a user who connected
your app: their inbox finished syncing, their contacts were categorised, an expense or payroll
operation was extracted, they connected or disconnected your app. You register a URL, we POST
signed JSON to it, you answer 2xx.
Everything on this page is scoped by consent: an event reaches your endpoint only for users who allowed your application, and only when your application holds the scopes that event requires.
1. Register an endpoint
Call the API with your application's own credentials (the client_id / client_secret
pair you use at /oauth2/token), over HTTP Basic:
curl -u "$CLIENT_ID:$CLIENT_SECRET" -X POST https://api.bind.co/v1/webhook-endpoints \
-H 'Content-Type: application/json' \
-d '{"url": "https://api.example.com/bind/webhooks", "description": "production"}'
{
"id": "we_2f1c7a1e-4b7d-4c1e-9c0e-5d2a1b3c4d5e",
"url": "https://api.example.com/bind/webhooks",
"description": "production",
"status": "active",
"event_types": null,
"secret": "whsec_…",
"created_at": "2026-09-11T08:00:00Z"
}
secret is shown once. Store it server-side; it is what you verify signatures with.
Rules: https:// only, a public host (private, loopback and link-local addresses are refused),
and a cap on active endpoints per application (§ 5). By default an endpoint receives every
event the application is eligible for. To subscribe to a subset, pass event_types — a
non-empty list of names from § 6, e.g. ["contacts.first_completed", "expenses.completed"];
the current list, including every operation type, is served as JSON at
/v1/docs/webhooks/event-types (no
authentication, 60 requests per minute per IP). An unknown name, or an operation type not in
the catalogue, is a 400
naming the offenders in hint.invalid_event_types. event_types: null (on create or patch)
means all events again.
Other endpoint calls, same authentication: GET /v1/webhook-endpoints (list),
GET /v1/webhook-endpoints/{public_id} (one endpoint),
PATCH /v1/webhook-endpoints/{public_id} (url, description, event_types,
status: active | disabled_by_user),
DELETE /v1/webhook-endpoints/{public_id} (disables it and cancels its pending deliveries),
POST /v1/webhook-endpoints/{public_id}/rotate-secret, POST /v1/webhook-endpoints/{public_id}/test.
Test it
POST /v1/webhook-endpoints/{public_id}/test sends a signed test event right away and returns what your
server answered — status code, duration, error class — so you can check your handler before any
real event exists. Rate-limited per endpoint (§ 5).
{"id": null, "type": "ping", "api_version": "v1", "occurred_at": "2026-09-11T08:00:00Z", "user_id": null, "data": {}}
It has no id and no user_id. Treat it like any event: verify the signature and answer 2xx.
2. What a delivery looks like
POST /bind/webhooks HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: bind.co-Webhooks/1.0
X-Webhook-Id: 9d2c… # this delivery; a redelivery gets a new one
X-Webhook-Event-Id: 7c9e… # the event; identical across retries, redeliveries and endpoints
X-Webhook-Event-Type: contacts.completed
X-Webhook-Timestamp: 1789459200 # unix seconds, when this attempt was signed
X-Webhook-Signature: v1=5f1a… # see § 3; two values during a secret rotation
X-Webhook-Attempt: 1
webhook-id: 9d2c… # Standard Webhooks (§ 3): equal to X-Webhook-Id
webhook-timestamp: 1789459200 # equal to X-Webhook-Timestamp
webhook-signature: v1,K5oZ… # see § 3; two values during a secret rotation
{
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "contacts.completed",
"api_version": "v1",
"occurred_at": "2026-09-11T08:00:00Z",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44",
"data": {"created": 42, "updated": 7, "user_inbox_fetch_id": "fch_0OPMCd3plzE"}
}
idandtypeidentify the event;datais the per-type payload (§ 6).user_idis the bind.co user the event concerns: the user idGET /v1/oauth2/clients/mereturns and the REST API takes asuser_id.- The body is compact JSON with sorted keys and ASCII escapes. When the whole body would exceed
the size limit (§ 5),
datais replaced by{"truncated": true}; fetch the details through the REST API using the ids in the envelope. - Bodies are byte-identical across retries; only
X-Webhook-Timestamp,X-Webhook-Signature,X-Webhook-Attempt,webhook-timestampandwebhook-signaturechange.
3. Verify the signature
Every request is signed with your endpoint secret:
signed_payload = "<X-Webhook-Timestamp>." + <raw request body bytes>
signature = hex(HMAC-SHA256(secret, signed_payload))
header = "v1=<signature>" # or "v1=<new>,v1=<old>" while a rotation overlaps
Verify before parsing JSON, on the raw bytes, with a constant-time comparison, and
reject timestamps outside the tolerance in § 5 (the samples use 5 minutes). Accept the request if
any v1= value matches — for the overlap window after a rotation (§ 5) we sign with both the
new and the previous secret so you can switch without a gap.
Python
The function below is the reference implementation bind.co itself tests against.
import hashlib
import hmac
import re
import time
from fastapi import FastAPI, Header, HTTPException, Request
SECRET = "whsec_…" # from your secrets store, never from the request
SIGNATURE_VERSION = "v1"
DEFAULT_TOLERANCE_SECONDS = 300
_HEX_SIGNATURE = re.compile(r"[0-9a-fA-F]{64}")
def sign(secret: str, timestamp: int, body: bytes) -> str:
signed_payload = f"{timestamp}.".encode("utf-8") + body
return hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
def parse_signature_header(header: str) -> list[str]:
"""Only well-formed `v1=<64 hex>` entries survive; anything else is ignored so a hostile
header can never make `verify` raise."""
signatures: list[str] = []
for part in header.split(","):
version, _, value = part.strip().partition("=")
if version == SIGNATURE_VERSION and _HEX_SIGNATURE.fullmatch(value):
signatures.append(value.lower())
return signatures
def verify(
secret: str,
header: str,
timestamp: int,
body: bytes,
*,
now: int,
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
) -> bool:
if abs(now - timestamp) > tolerance_seconds:
return False
expected = sign(secret, timestamp, body)
return any(hmac.compare_digest(expected, candidate) for candidate in parse_signature_header(header))
app = FastAPI()
@app.post("/bind/webhooks")
async def receive(request: Request,
x_webhook_signature: str = Header(...),
x_webhook_timestamp: int = Header(...),
x_webhook_event_id: str = Header(...)) -> dict[str, bool]:
body = await request.body()
if not verify(SECRET, x_webhook_signature, x_webhook_timestamp, body, now=int(time.time())):
raise HTTPException(status_code=401)
if already_processed(x_webhook_event_id): # your idempotency store, see § 4
return {"ok": True}
enqueue_for_processing(x_webhook_event_id, body) # answer first, work later
return {"ok": True}
Node (Express)
const crypto = require("node:crypto");
const express = require("express");
const SECRET = process.env.BIND_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function sign(secret, timestamp, rawBody) {
return crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
}
function verify(secret, header, timestamp, rawBody, now = Math.floor(Date.now() / 1000)) {
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(sign(secret, timestamp, rawBody), "hex");
return header.split(",").some((part) => {
const [version, value] = part.trim().split("=");
if (version !== "v1" || !/^[0-9a-fA-F]{64}$/.test(value ?? "")) return false;
return crypto.timingSafeEqual(expected, Buffer.from(value, "hex"));
});
}
const app = express();
app.post("/bind/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = Number(req.get("X-Webhook-Timestamp"));
if (!verify(SECRET, req.get("X-Webhook-Signature") ?? "", timestamp, req.body)) {
return res.sendStatus(401);
}
const eventId = req.get("X-Webhook-Event-Id");
if (!alreadyProcessed(eventId)) enqueueForProcessing(eventId, req.body);
res.sendStatus(200);
});
Use express.raw (or bodyParser.raw) on this route: a JSON body parser re-serialises the
payload and the signature no longer matches.
Standard Webhooks
Every request is also signed to the Standard Webhooks specification, so you can verify it with one of its official libraries instead of the code above:
key = base64-decode(<secret without the "whsec_" prefix>)
signature = base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw request body>"))
header = "v1,<signature>" # or "v1,<new> v1,<old>" while a rotation overlaps
from standardwebhooks.webhooks import Webhook, WebhookVerificationError # pip install standardwebhooks
webhook = Webhook(SECRET) # the "whsec_…" secret, prefix included
@app.post("/bind/webhooks")
async def receive(request: Request) -> dict[str, bool]:
body = await request.body()
try:
webhook.verify(body, dict(request.headers)) # checks webhook-signature and the 5-minute tolerance
except WebhookVerificationError:
raise HTTPException(status_code=401)
return {"ok": True}
const { Webhook } = require("standardwebhooks"); // npm install standardwebhooks
const webhook = new Webhook(SECRET);
app.post("/bind/webhooks", express.raw({ type: "application/json" }), (req, res) => {
try {
webhook.verify(req.body, req.headers);
} catch {
return res.sendStatus(401);
}
res.sendStatus(200);
});
These libraries read the secret as whsec_ followed by standard base64, the form every secret
bind.co issues today has. A secret issued before 2026-10-22 is whsec_ followed by URL-safe base64
without padding: bind.co signs webhook-signature with the same 32 bytes it encodes, but the
libraries cannot read that form. Rotate the secret (§ 8) to use them, or keep verifying
X-Webhook-Signature as above, which works with every secret.
4. Handle deliveries correctly
- Answer
2xxwithin 10 seconds, then process. We wait 5 s to connect and 10 s to read; a slow handler is a failed attempt and is retried. Put the work on a queue and return. - Deduplicate on
X-Webhook-Event-Id(alsoidin the body). The same event can arrive more than once — a retry after a timeout you actually handled, a redelivery you asked for, or the same event fanned out to two of your endpoints. Store processed ids and treat a repeat as already done. - Ordering is not guaranteed. Retries and parallel endpoints can reorder events; use
occurred_at(and the ids insidedata) rather than arrival order. - A new user's inbox arrives in stages. The first sync stores the most recent mail and
announces
email_sync.first_completed— enough to show something. The history is then downloaded in two more syncs; the second announces nothing, the third announcesemail_sync.history_completed, after which the user's inbox is complete and every later sync is incremental and announcesemail_sync.completed.fetch_cardinal_numberindatais the sync's position (1, 3, 4, …). - The first of anything is its own event. For the business profile, contacts, drafts and
every operation type, the very first records of that kind ever held for a user are announced
as
<family>.first_completed. For the business profile, contacts and drafts that happens the moment they are committed, without waiting for the run to finish; for operation types it comes with the rest of the run. Every other batch is<family>.completedwith its own counts. "First" belongs to the user, not to you: it is sent once per user and goes to every application connected and subscribed at that moment. Connect to a user who already has contacts and your first batch is acontacts.completed— useconsent.grantedplus a read of the collection as your own "this user is ready" signal. Both types carry the samedata; sumcreatedandupdatedacross events for a running total. If none of your endpoints subscribes to<family>.first_completed, the user's first batch still reaches you — as a<family>.completed. Do not subscribe an endpoint to<family>.first_completedalone. Because the first belongs to the user, an application that connects to someone who already has records of that family will never receive one, and such an endpoint would stay silent forever. Subscribe to<family>.completedas well, or to both. - How many events a run produces. For
contactsanddrafts, an ordinary run announces once, at the end; the user's first run announces as it commits and then once more for the rest of that run.profileannounces once when it is first built and once each time it is refreshed from the inbox (every 90 days). An operation type announces once per sync, at the end. Itscreatedcovers everything that sync produced; itsupdatedcovers only the attempt that reached the end, so a sync that was interrupted and resumed can report fewer updates than it actually made. A run that created and updated nothing announces nothing, whatever the family. Treat these as upper bounds rather than a fixed count: key your own deduplication on the eventid, which never repeats. - Redirects are not followed. A
3xxis a failure. Register the final URL. - Do not allow-list our IP addresses. bind.co makes no static egress IP guarantee; verify signatures instead of allow-listing addresses.
5. Retries, dead letters and auto-disable
An attempt succeeds on any 2xx. Anything else — a 4xx, a 5xx, a timeout, a connection or TLS
error — is retried on the schedule below. The tables in this section are generated from the
service defaults.
| Attempt | Waits after the previous one |
|---|---|
| 1 | — |
| 2 | 30 s |
| 3 | 1 min |
| 4 | 2 min |
| 5 | 4 min |
| 6 | 8 min |
| 7 | 16 min |
| 8 | 32 min |
| 9 | 48 min |
| 10 | 64 min |
| 11 | 80 min |
| 12 | 96 min |
| 13 | 112 min |
| 14 | 128 min |
| 15 | 144 min |
| 16 | 160 min |
| 17 | 176 min |
| 18 | 192 min |
| 19 | 208 min |
| 20 | 224 min |
| 21 | 4 h |
| 22 | 256 min |
| 23 | 272 min |
| 24 | 288 min |
| 25 | 304 min |
| 26 | 320 min |
| 27 | 336 min |
| 28 | 352 min |
| 29 | 6 h |
29 attempts in total, ≈ 73 h 43 min end to end.
| Limit | Value |
|---|---|
| Connect / read timeout per attempt | 5 s / 10 s |
| Hard cap per attempt | 15 s |
Body size before data is truncated |
256 KB |
| Signature timestamp tolerance | 5 min |
| Previous secret accepted after rotation | 24 h |
| Active endpoints per application | 10 |
| Auto-disable | 50 consecutive failures with no success in 24 h |
| Test deliveries per endpoint | 10 / min |
| Redeliveries per application | 60 / min |
Two responses stop retries immediately: 410 Gone (we take it as "this URL is retired") and a
URL that no longer passes validation. After the last attempt the delivery is dead — it stays
in your delivery log and you can redeliver it (§ 7).
Auto-disable. An endpoint that crosses the failure limit above is disabled
(status: disabled_by_system, disabled_reason says why) and its pending deliveries are
cancelled. Fix the receiver, then re-enable it with
PATCH /v1/webhook-endpoints/{public_id} {"status": "active"}; the failure counter resets. Events
emitted while it was disabled are not queued for it — use the event log to find what you missed.
6. Event catalogue
Every event type, who receives it, the fields in data, and a full example body. Operation
events exist for every operation type in the catalogue: <operation_type> stands for any
operation_type returned by GET /v1/operations, and a type added to the catalogue starts
emitting without a change here. This block is generated from the registry.
consent.granted
The user allowed your application to access their inbox through bind.co.
Who receives it: Only the application the event concerns.
| Field | Type |
|---|---|
client_public_id |
string |
scopes |
array of strings |
{
"api_version": "v1",
"data": {
"client_public_id": "acme-payroll",
"scopes": [
"profile:read",
"operations:payroll_operations:read"
]
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "consent.granted",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
consent.revoked
The user disconnected your application; no further events will be sent for them.
Who receives it: Only the application the event concerns.
| Field | Type |
|---|---|
client_public_id |
string |
{
"api_version": "v1",
"data": {
"client_public_id": "acme-payroll"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "consent.revoked",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
email_sync.first_completed
The user's first inbox sync finished: the most recent mail is stored and the history download is under way.
Who receives it: Every application the user has connected.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
fetch_type |
string |
fetch_cardinal_number |
integer |
had_work |
boolean |
{
"api_version": "v1",
"data": {
"fetch_cardinal_number": 1,
"fetch_type": "email",
"had_work": true,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "email_sync.first_completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
email_sync.history_completed
The history download finished: everything older than the first sync is stored; from here on syncs are incremental.
Who receives it: Every application the user has connected.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
fetch_type |
string |
fetch_cardinal_number |
integer |
had_work |
boolean |
{
"api_version": "v1",
"data": {
"fetch_cardinal_number": 3,
"fetch_type": "email",
"had_work": true,
"user_inbox_fetch_id": "fch_CZdKjZXJ2JD"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "email_sync.history_completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
email_sync.completed
A routine inbox sync finished: new headers, bodies and attachments since the previous sync are stored.
Who receives it: Every application the user has connected.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
fetch_type |
string |
fetch_cardinal_number |
integer |
had_work |
boolean |
{
"api_version": "v1",
"data": {
"fetch_cardinal_number": 7,
"fetch_type": "email",
"had_work": true,
"user_inbox_fetch_id": "fch_7QfFQiCrtDk"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "email_sync.completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
profile.first_completed
The user's profile was built for the very first time (created is 1). Sent once per user, whichever applications are connected then.
Who receives it: Applications holding at least one of profile:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
created |
integer |
updated |
integer |
{
"api_version": "v1",
"data": {
"created": 1,
"updated": 0,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "profile.first_completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
profile.completed
The user's profile was refreshed from their inbox, which happens every 90 days (created is 0 and updated is 1). The first build is profile.first_completed.
Who receives it: Applications holding at least one of profile:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
created |
integer |
updated |
integer |
{
"api_version": "v1",
"data": {
"created": 0,
"updated": 1,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "profile.completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
profile.suggestion_available
A profile a person has saved has a newer suggestion from the inbox. Read it from the resource's suggestions route; accept it with edit, or keep what you have with confirm. A company-profile suggestion is sent to each of the company's admins and subjects, with user_inbox_fetch_id null; one for user-profile carries the user's own fetch.
Who receives it: Applications holding at least one of profile:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…), null |
resource |
string, one of 'user-profile', 'company-profile' |
company_id |
string (cmp_…), null |
{
"api_version": "v1",
"data": {
"company_id": "cmp_3f2b8c1e-5d4a-4e6f-9a7b-1c2d3e4f5a6b",
"resource": "company-profile",
"user_inbox_fetch_id": null
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "profile.suggestion_available",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
contacts.first_completed
The very first batch of categorised contacts (including contact roles) ever recorded for this user is committed: created new contact roles, updated re-scored ones. Sent once per user; connect later and your first batch is a contacts.completed.
Who receives it: Applications holding at least one of contacts:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
created |
integer |
updated |
integer |
{
"api_version": "v1",
"data": {
"created": 20,
"updated": 0,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "contacts.first_completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
contacts.completed
A batch of contact categorisation (including contact roles) beyond the user's first is committed: created new contact roles, updated re-scored ones.
Who receives it: Applications holding at least one of contacts:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
created |
integer |
updated |
integer |
{
"api_version": "v1",
"data": {
"created": 42,
"updated": 7,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "contacts.completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
contacts.suggestion_available
Contacts a person has saved have a newer suggestion from the inbox; one event per committed batch. Read each from the contact's suggestions route; accept it with edit, or keep what you have with confirm.
Who receives it: Applications holding at least one of contacts:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
contact_ids |
array of strings (con_…) |
{
"api_version": "v1",
"data": {
"contact_ids": [
"con_3qeHQKbrPqQ",
"con_KsNvcP0bZKK"
],
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "contacts.suggestion_available",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
contact_roles.suggestion_available
Contact roles a person has saved have a newer suggestion from the inbox; one event per committed batch. Read each from the role's suggestions route; accept it with edit, or keep what you have with confirm.
Who receives it: Applications holding at least one of contacts:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
category_contact_role_ids |
array of strings (rol_…) |
{
"api_version": "v1",
"data": {
"category_contact_role_ids": [
"rol_3DwEmtYQY9E",
"rol_8D9TwvpGsPO"
],
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "contact_roles.suggestion_available",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
drafts.first_completed
The very first draft proposal ever recorded for this user is committed (updated is always 0; drafts are never re-proposed in place). Sent once per user.
Who receives it: Applications holding at least one of drafts:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
created |
integer |
updated |
integer |
{
"api_version": "v1",
"data": {
"created": 1,
"updated": 0,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "drafts.first_completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
drafts.completed
More draft proposals were generated for this fetch (updated is always 0; drafts are never re-proposed in place).
Who receives it: Applications holding at least one of drafts:read; a :write scope includes its :read.
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…) |
created |
integer |
updated |
integer |
{
"api_version": "v1",
"data": {
"created": 5,
"updated": 0,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "drafts.completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
documents.suggestion_available
Documents whose type or fields a person has set were read again and the reading now differs from what they set; one event per committed batch. What they set is kept. The routes for files and documents are not in the public reference yet.
Who receives it: Applications holding at least one of files:read; a :write scope includes its :read.
| Field | Type |
|---|---|
file_ids |
array of strings (fil_…) |
{
"api_version": "v1",
"data": {
"file_ids": [
"fil_2wJyhgOT8GH",
"fil_4m0ww95XulT"
]
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "documents.suggestion_available",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
application.suspended
Your application was suspended (usage_limit: its AI allowance for the period ran out; bind_admin: bind.co suspended or delisted it). One event per connected user. Requests are refused and no events are sent until application.resumed; work on the user's inbox waits, so records may arrive late after the restart.
Who receives it: Only the application the event concerns.
| Field | Type |
|---|---|
client_public_id |
string |
reason |
string, one of 'usage_limit', 'bind_admin' |
suspended_at |
string (ISO 8601 date-time) |
{
"api_version": "v1",
"data": {
"client_public_id": "acme-payroll",
"reason": "usage_limit",
"suspended_at": "2026-09-11T08:00:00Z"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "application.suspended",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
application.resumed
Your application is active again. One event per connected user. Events that would have been sent while suspended are not replayed: read the user's records changed since suspended_at. Work that waited during the suspension runs now, so expect a burst of events.
Who receives it: Only the application the event concerns.
| Field | Type |
|---|---|
client_public_id |
string |
suspended_at |
string (ISO 8601 date-time) |
resumed_at |
string (ISO 8601 date-time) |
{
"api_version": "v1",
"data": {
"client_public_id": "acme-payroll",
"resumed_at": "2026-09-11T08:00:00Z",
"suspended_at": "2026-09-10T06:00:00Z"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "application.resumed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
mailbox.access_lost
bind.co can no longer read the user's mailbox: they revoked access, changed their password, or an admin removed it. No new records arrive until they sign in to bind.co again. What you already received is unchanged.
Who receives it: Every application the user has connected.
| Field | Type |
|---|---|
provider |
string, one of 'gmail', 'microsoft' |
lost_at |
string (ISO 8601 date-time) |
{
"api_version": "v1",
"data": {
"lost_at": "2026-09-11T08:00:00Z",
"provider": "gmail"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "mailbox.access_lost",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
mailbox.access_restored
The user reconnected their mailbox after mailbox.access_lost; syncing resumes and mail that arrived meanwhile is picked up.
Who receives it: Every application the user has connected.
| Field | Type |
|---|---|
provider |
string, one of 'gmail', 'microsoft' |
restored_at |
string (ISO 8601 date-time) |
{
"api_version": "v1",
"data": {
"provider": "gmail",
"restored_at": "2026-09-11T08:00:00Z"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "mailbox.access_restored",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
<operation_type>.completed
A batch of operations of this type beyond the user's first was extracted for the user. operations_created is the number of new operations (for an email fetch, every one the fetch produced); operations_updated is the number of emails or documents in this batch that attached to an existing operation, so one operation can be counted more than once.
Who receives it: Applications holding :read (or :write) on every data area the active <operation_type> schema requires (required_consent_scopes).
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…), null |
operation_type |
string |
operations_created |
integer |
operations_updated |
integer |
{
"api_version": "v1",
"data": {
"operation_type": "<operation_type>",
"operations_created": 3,
"operations_updated": 1,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "<operation_type>.completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
<operation_type>.first_completed
The very first operations of this type ever recorded for this user are committed; sent once per user, whichever applications are connected then. operations_created is the number of new operations (for an email fetch, every one the fetch produced); operations_updated is the number of emails or documents in this batch that attached to an existing operation, so one operation can be counted more than once.
Who receives it: Applications holding :read (or :write) on every data area the active <operation_type> schema requires (required_consent_scopes).
| Field | Type |
|---|---|
user_inbox_fetch_id |
string (fch_…), null |
operation_type |
string |
operations_created |
integer |
operations_updated |
integer |
{
"api_version": "v1",
"data": {
"operation_type": "<operation_type>",
"operations_created": 3,
"operations_updated": 1,
"user_inbox_fetch_id": "fch_0OPMCd3plzE"
},
"id": "evt_7c9e6679-7425-40de-944b-e07fc1f90ae7",
"occurred_at": "2026-09-11T08:00:00Z",
"type": "<operation_type>.first_completed",
"user_id": "usr_3f2b8c1e-6a4d-4e9b-9c2f-1d7a5b8e0c44"
}
7. Delivery log, event log and redelivery
Same authentication as § 1.
GET /v1/webhook-deliveries?endpoint_id=&event_id=&event_type=&status=&created_after=&created_before=&page_number=&page_size=&page_token=— every delivery to your endpoints, newest first;statusis one ofpending,succeeded,failed(retrying),dead,cancelled.event_idlists the deliveries of one event.GET /v1/webhook-deliveries/{public_id}— the delivery, every attempt (status_code,duration_ms,error_class,error_message,response_body_excerpt) and the full event.POST /v1/webhook-deliveries/{public_id}/redeliver— queue the same event to the same endpoint again (202, a new delivery id).GET /v1/webhook-events?type=&user_id=&occurred_after=&occurred_before=&page_number=&page_size=&page_token=andGET /v1/webhook-events/{public_id}— events you received, with their payload. Each event carries the user's id asuser_id; filter withuser_id=set to it.POST /v1/webhook-events/{public_id}/redeliver— queue the event to every active endpoint of yours that is still eligible for it.
Redelivery re-checks consent at the moment it runs: an event for a user who has since
disconnected your application is not sent. Redeliveries are rate-limited per application (§ 5);
over the limit you get 429 — wait a minute and retry.
8. Rotate a secret
POST /v1/webhook-endpoints/{public_id}/rotate-secret returns a new secret (again shown once), in the
Standard Webhooks form. For the overlap window in § 5 every request carries two signatures — new
and old — in both X-Webhook-Signature and webhook-signature, so deploy the new secret to your
receiver at your own pace, then forget the old one.
9. Errors you may see from the API
| Status | error |
Meaning |
|---|---|---|
400 |
invalid_request |
The URL is not https:// or points at a non-public address (hint.reason). |
401 |
invalid_client |
Wrong or missing application credentials. |
404 |
not_found |
Not your endpoint / delivery / event. |
409 |
conflict |
The active-endpoint cap is reached (hint.max_active_endpoints), or the delivery/event cannot be redelivered in its current status (hint.current_status). |
429 |
rate_limited |
Redeliver or test budget spent for this minute (hint.limit_per_minute, hint.retry_after_seconds). |
Every error carries error, error_description, hint and a request_id to quote to support.