> ## Documentation Index
> Fetch the complete documentation index at: https://developers.investorlift.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks and events

> Endpoints, the signed delivery, the event shape and catalogue, what an event carries about a buyer, the events feed and the replay recipe.

<Note>
  The Marketplace API is in a private beta. It answers only for accounts Investorlift has enabled. [Access](/marketplace/access) says how to ask for one. A route shape on these pages can change before the beta ends. The changelog records every change.
</Note>

A write on Mogul or through the API emits an event. You read events in two ways. A webhook endpoint gets each event
pushed and signed. The events feed serves the same events in sequence order. Use both: the webhook for speed, the feed
for replay.

## Endpoints

Create an endpoint with `POST /sell/webhooks` or `POST /buy/webhooks` under the scope `webhooks:manage`. On the sell
side, an owner or an admin creates it. The body names the URL and the event types. Each side holds ten endpoints, and
the eleventh answers `409 webhook_limit`.

`GET` lists the endpoints with the creator, the client and the last delivery. `PATCH /sell/webhooks/{webhook_id}`
changes the event types, pauses the endpoint or starts it again. `rotate_secret: true` in the same body mints a new
signing secret, shown once. `DELETE` removes one. The `/buy/` side has the same routes.

`POST /sell/webhooks/{webhook_id}/test` sends a `ping` event to that endpoint alone, and
`POST /buy/webhooks/{webhook_id}/test` does the same on the buy side. The test answers `202` with
`data: { endpoint_id, event_id, type }`, and `type` reads `ping`. Read the delivery in `last_delivery` on the next read
of the endpoint.

* The URL is `https` only, on a public host, with no user name or password. The API resolves the host at create and
  again before every delivery. A host that resolves to a private, loopback, link-local or metadata address answers
  `422 webhook_url_refused` at create, and takes no delivery later. The API follows no redirect: a `3xx` is a failed
  delivery.
* Before the endpoint is `active`, the API sends a challenge to the URL and expects it echoed. Until then the endpoint
  reads `pending_verification`. The other states are `paused` and `disabled`.
* The create response shows the signing secret once. It starts with `whsec_`. Store it. Every later read shows its last
  four characters only. The secret rotates through `PATCH` with `rotate_secret: true`. During a rotation a delivery
  carries the old and the new signature.
* The endpoint pins `api_version` at create. Every delivery to it renders the event at that version. A payload change
  ships as a new version, and you opt into it with a new endpoint.
* The endpoint records its creator and client. When the creator leaves the organization or revokes your application,
  the endpoint pauses. The owner of the organization then gets an email to confirm or delete it within 7 days.
* A paused endpoint names the cause in `paused_reason`: `creator_left`, `consent_revoked` or `client_disabled`. An owner
  or an admin starts it again with `status` set to `active`.

## The signed delivery

Every delivery is a `POST` with the content type `application/json`, the `User-Agent`
`Investorlift-Marketplace-Webhooks/1`, and the three headers of the Standard Webhooks specification:

| Header              | What it carries                                                                                                                         |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | The event id, `evt_`. It is also your dedupe key.                                                                                       |
| `webhook-timestamp` | The signing instant, in seconds since the epoch.                                                                                        |
| `webhook-signature` | `v1,` and a base64 HMAC-SHA256 with your secret over `<id>.<timestamp>.<body>`. During a rotation, two signatures separated by a space. |

Verify before you parse. Compute the HMAC over the exact bytes of the body. Compare with a constant-time comparison.
Refuse a timestamp more than 5 minutes from your clock. Answer `2xx` inside 15 seconds, and do the work after.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  // secret: the whsec_ string from the create response. rawBody: the body bytes as a string, before any parse.
  export function verifyDelivery(headers, rawBody, secret) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - Number(timestamp)) > 300) return false;
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const digest = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
    const expected = Buffer.from(`v1,${digest}`);
    return headers["webhook-signature"].split(" ").some((candidate) => {
      const given = Buffer.from(candidate);
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
  }
  ```

  ```python Python theme={null}
  import base64
  import hashlib
  import hmac
  import time


  def verify_delivery(headers: dict, raw_body: bytes, secret: str) -> bool:
      event_id = headers["webhook-id"]
      timestamp = headers["webhook-timestamp"]
      if abs(time.time() - int(timestamp)) > 300:
          return False
      key = base64.b64decode(secret.removeprefix("whsec_"))
      signed = f"{event_id}.{timestamp}.".encode() + raw_body
      digest = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
      expected = f"v1,{digest}"
      return any(hmac.compare_digest(candidate, expected) for candidate in headers["webhook-signature"].split(" "))
  ```
</CodeGroup>

## Delivery

Delivery is at least once and unordered. The event id is your dedupe key: store it, and skip a delivery whose id you
hold. A delivery that gets no `2xx` inside 15 seconds is a failure, and so is a `3xx`. The API retries a failure five times over about
four minutes with backoff, then once an hour. An endpoint that fails for 3 days is `disabled`: the API emails the owner
and emits `webhook.disabled`. The API keeps a payload 7 days for redelivery, then keeps its hash.

## The event shape

One event has one shape on the feed and on a webhook:

```json theme={null}
{
  "id": "evt_7Qk2mN9pR4sT",
  "type": "offer.created",
  "sequence": 48213,
  "occurred_at": "2026-10-02T14:03:07Z",
  "api_version": "2026-09-18",
  "data": {
    "id": "ofr_9Zx8Yw7Vu6Ts",
    "deal_id": "mdl_a1b2c3d4e5f6",
    "buyer_id": "byr_3Fg4Hj5Kl6Mn",
    "status": "new",
    "offer_amount": 231000,
    "emd_amount": 5000,
    "financing": "cash",
    "round": 1,
    "buyer": {
      "buyer_id": "byr_3Fg4Hj5Kl6Mn",
      "name": "Dana Rivera",
      "entity_name": "RIVERA CAPITAL LLC"
    }
  },
  "actor": { "kind": "person", "id": null, "client_id": null }
}
```

| Field         | What it is                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`          | The event id, `evt_`. The dedupe key.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `type`        | One type of the catalogue below. The list of each side is closed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `sequence`    | The position of the event in the feed of your side. It grows and never repeats.                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `occurred_at` | When the write committed, RFC 3339 in UTC.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `api_version` | The contract version of the payload.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `data`        | A snapshot of the resource at that instant, in the shape the matching read serves. Every seller-side event carries `deal_id`.                                                                                                                                                                                                                                                                                                                                                                                                        |
| `actor`       | Who caused it. `kind` is `person` when a person acted, through the API or in the Investorlift app. It is `system` for a job or a rule. `id` is the member who acted, as `mbr_`. It is null for the system, and null when the other side of the deal acted. `client_id` is the application the member called through. It is null for an act in the Investorlift app, and null when the other side acted. In the example a buyer made the offer, so the seller-side event carries null in both. Use `data.buyer_id` to name the buyer. |

## The catalogue

The event types are a closed list for each side. An endpoint takes the types of its own side and the two compliance
types, and no other type. The OpenAPI document of this API lists each event type in its `webhooks` section, with the
three headers and the event shape.

The sell side. The owner is the organization.

| Event                       | When                                                                                                                                             |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `deal.published`            | A draft became a live deal.                                                                                                                      |
| `deal.updated`              | The organization changed the price, the terms or the text of a live deal. The event names the fields that changed.                               |
| `deal.status_changed`       | The deal moved to another status. The sweep that expires a deal fires it with `actor.kind` `system`.                                             |
| `deal.verification_changed` | The verification of the deal by Investorlift changed.                                                                                            |
| `offer.created`             | A buyer submitted an offer, or the seller logged an off-platform offer. An off-platform offer reaches the seller's own endpoints only.           |
| `offer.countered`           | One side opened the next round.                                                                                                                  |
| `offer.accepted`            | One side accepted the open round. A `deal.status_changed` event follows when the deal moves to pending.                                          |
| `offer.declined`            | One side declined the open round.                                                                                                                |
| `offer.withdrawn`           | The buyer withdrew the open offer.                                                                                                               |
| `offer.held`                | The hold queue took an offer. The event carries the `hold_id` of the `202` answer.                                                               |
| `inquiry.created`           | A buyer sent an inquiry.                                                                                                                         |
| `inquiry.held`              | The hold queue took an inquiry or an address request.                                                                                            |
| `address_request.created`   | A buyer asked for the address.                                                                                                                   |
| `address_request.approved`  | The seller shared the address.                                                                                                                   |
| `address_request.declined`  | The seller declined the request.                                                                                                                 |
| `lead.created`              | A buyer acted on a deal for the first time, or the seller added a lead. For a recommended lead it fires at the first act, not at the suggestion. |
| `lead.status_changed`       | A lead moved to another status. An offer moves its lead to `offer_made`.                                                                         |
| `review.created`            | A buyer reviewed the organization.                                                                                                               |
| `client.revoked`            | A person revoked your application.                                                                                                               |
| `webhook.disabled`          | Investorlift paused or disabled an endpoint, and no later event reaches its URL. An endpoint that fails for 3 days is one cause.                 |

The buy side. The owner is the buyer.

| Event                      | When                                                                                                                  |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `deal.matched`             | A new deal matches a buy box whose `alerts.webhook` is true. Set the switch with `PATCH /buy/buy-boxes/{buy_box_id}`. |
| `deal.status_changed`      | A deal the buyer has an offer on moved to another status.                                                             |
| `offer.created`            | The buyer made an offer. The buyer's own integration reads its own act back.                                          |
| `offer.countered`          | One side opened the next round.                                                                                       |
| `offer.accepted`           | One side accepted the open round. A `deal.status_changed` event follows when the deal moves to pending.               |
| `offer.declined`           | One side declined the open round, and the negotiation closed.                                                         |
| `offer.withdrawn`          | The buyer withdrew the open round.                                                                                    |
| `offer.held`               | The hold queue took an offer or a counter of the buyer.                                                               |
| `inquiry.held`             | The hold queue took an inquiry or an address request of the buyer.                                                    |
| `address_request.approved` | The seller shared the address.                                                                                        |
| `address_request.declined` | The seller declined the request.                                                                                      |
| `webhook.disabled`         | Investorlift paused or disabled a buy-side endpoint, and no later event reaches its URL.                              |

Compliance, both sides. `buyer.redacted` and `lead.redacted` fire on account deletion or a verified deletion request.
Each carries one id and nothing else. On `buyer.redacted`, delete the buyer's contact from your systems within 10
business days and keep the id as a tombstone ([Terms](/marketplace/terms)).

`deal.matched` carries `deal_id`, `city`, `state`, `zip`, `asking_price`, `deal_type` and `matched_buy_box_id`, and no
other deal field. The full read is `GET /buy/deals/{deal_id}` against the deal budget, and the delivery itself counts
one deal row.

## What an event says about a buyer

A seller-side event about an offer, an inquiry, an address request or a lead carries
`buyer: { buyer_id, name, entity_name }`. The fields `email`, `phone` (one number) and `phone_type` ride only when the
creator of the endpoint holds `contacts:read`, an owner or an admin. On the feed, the token that reads must hold it. A
token without it gets no such key: absent, never null. Each row with `email` or `phone` counts against the daily
contact-bearing row cap ([Trust and limits](/marketplace/concepts/trust-and-limits#the-seller-caps)). A delivery past
that cap carries no contact members, and `data.contact.status` reads `budget_reached`.

No event carries a score, a rank, a breakdown or a channel of a recommended lead. `lead.created` carries a `consent`
block with `source`, `captured_at`, `text_version` and `sms_opt_in`. You are the sender under the TCPA and CAN-SPAM for
anything you do with the contact ([Terms](/marketplace/terms)). A new organization is one with fewer than five verified
deals or under 90 days of standing. It gets no `email` or `phone` on an API-published deal until Investorlift verifies
the deal. Such a row carries `contact: { status: "pending_verification" }`.

## The events feed

`GET /sell/events` and `GET /buy/events` serve the events of the side under the scope `events:read`, in `sequence`
order, in the shape above. The query takes four parameters:

* `cursor=`, the `page.next_cursor` of the page before this one
* `types=`, a comma list of event types
* `from=`, an RFC 3339 instant for a first read
* `limit=`, the page size, 1 to 100, 50 by default

Events stay 30 days.

The feed lags 5 seconds behind now, so a late commit never lands behind your cursor. The cursor is a sequence position
bound to your account, not to the filter: change `types=` and keep the cursor. A cursor from another account answers
`400 invalid_parameter`. Read the feed again without a cursor.

## Replay

<Steps>
  <Step title="Store the id and the instant of every event you handle">
    From a webhook, store `id` and `occurred_at` from the body. From the feed, also store `page.next_cursor`.
  </Step>

  <Step title="Poll the feed with the cursor">
    Send `GET /sell/events?cursor=...` from your last cursor. Handle every event whose id you do not hold. Follow
    `next_cursor` until it is null.
  </Step>

  <Step title="After a gap or an outage, start from an instant">
    Send `from=` with the `occurred_at` of your last handled event, inside 30 days. Skip the ids you hold. Beyond 30 days,
    read the resources with `updated_since=` instead.
  </Step>
</Steps>

[Identifiers and the envelope](/marketplace/concepts/identifiers-and-envelope#pagination) has the list cursor.
[What the API returns](/marketplace/concepts/what-the-api-returns) has the recommended-lead rule.


## Related topics

- [Marketplace API Terms](/marketplace/terms.md)
- [Errors](/marketplace/concepts/errors.md)
- [Quickstart for sellers](/marketplace/quickstart-seller.md)
- [List the seller-side webhook endpoints](/marketplace/reference/list-sell-webhooks.md)
- [List the buy-side webhook endpoints](/api-reference/buy/list-the-buy-side-webhook-endpoints.md)
