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"}
}

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

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.

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.