Metadata-Version: 2.5
Name: lighthouse-private-markets-sdk
Version: 0.13.0
Summary: Python SDK for the Lighthouse REST API (CRM, Discovery, Reports, Documents).
Project-URL: Homepage, https://trylighthouse.vc
Author-email: Alberto Marzetta <hello@trylighthouse.vc>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.8
Requires-Dist: requests>=2.28
Description-Content-Type: text/markdown

# Lighthouse Python SDK

Official Python client for the [Lighthouse](https://trylighthouse.vc) REST API. Wraps the CRM, Discovery, Reports, and Documents endpoints behind a single typed `Lighthouse` class.

## Installation

```bash
pip install lighthouse-private-markets-sdk
```

## Quick Start

Generate an API key at **Settings → API Keys** in your Lighthouse workspace.

```python
from lighthouse import Lighthouse

client = Lighthouse("lgt_live_...")

# Who am I?
me = client.users.me()  # profile, workspace role, permissions and teams
print(me.data)

# Search your CRM
res = client.records.search(
    "company",
    filters={
        "operator": "AND",
        "conditions": [
            {"field": "domain", "operator": "contains_any", "value": ["acme.com"]}
        ],
    },
    limit=10,
)
print(res.data, res.meta)
```

Every method returns an `APIResponse`:

```python
class APIResponse:
    data:   Any                    # response payload
    error:  Any | None             # error message if the call failed
    meta:   dict | None            # pagination/meta info (when present)
    status: int                    # HTTP status code

res.raise_for_error()              # raises LighthouseError if error is set
```

## Authentication

The client sends `Authorization: Bearer <api_key>` on every request. Keep API keys secret. Never ship them in client-side code.

## Resources

| Namespace             | Endpoints |
|-----------------------|-----------|
| `client.records`      | search / get / create / update / delete records (company, person, deal, custom objects) |
| `client.lists`        | list / search / get / create / update / delete, get_records, add_record, remove_record, share/revoke teams, update_sharing |
| `client.notes`        | list / get / create / delete, link_record, unlink_record |
| `client.tasks`        | list / get / create / update / delete |
| `client.attributes`   | list / get / create / update / delete, colors |
| `client.options`      | list / create / delete select-field options |
| `client.views`        | list / get / update / delete, share/revoke teams, update_sharing |
| `client.users`        | me, list, get |
| `client.teams`        | list, get |
| `client.dashboards`   | list / get / create / update / delete, list_widgets, create_widget, update_widget, delete_widget, widget_data, share/revoke teams |
| `client.discovery`    | get_attributes, search_companies, search_people, semantic_search_companies, semantic_search_people, search_entities, get_entity_by_name, lookup_companies_by_domain, lookup_companies_by_linkedin, lookup_people_by_linkedin, get_saved_searches, get_search_results, get_locations, get_industries, get_tags, search_investors (deprecated), search_organizations (deprecated), search_schools (deprecated) |
| `client.documents`    | folders + files (v2): list_folders, create_folder, get_folder, update_folder, delete_folder, link/unlink_folder, upload_url, get_file, get_file_content, update_file, delete_file, link/unlink_file, list_record_documents, `upload()` one-shot helper |
| `client.inbox`        | email_account_me, email_accounts, email_threads, email_thread, create_email_draft, email_attachment_content, download_email_attachment, linkedin_account_me, linkedin_accounts, linkedin_chats, linkedin_chat_messages, linkedin_drafts, create_linkedin_draft, delete_linkedin_draft, download_linkedin_attachment, calendar_account_me, calendar_accounts, calendar_meetings, calendar_meeting, calendar_meeting_notes, calendar_meeting_transcript |
| `client.network`      | intro_paths |
| `client.ai_reports`   | list, get |
| `client.webhook_subscriptions` | list, get, create, delete (and the standalone `verify_webhook`) |

## Examples

### Inbox

Email, LinkedIn and calendar speak one vocabulary. The same concept is the same
key on every channel: `id`, `date`, `from`, `direction`, `text`, `is_read`,
`participants`, `last_message`, `attachments`, `account`. People are objects
(`{"name", "email"}` on email and calendar, a profile object on LinkedIn), empty
is `None`, `[]` or `False`, a fact reads `is_...` or `has_...`, and a link ends
in `_url`.

```python
# Accounts you can access (own and shared), with the permission flags.
# One account shape on all three channels: id, name, email, is_owner, provider,
# status, status_reason, owner, permissions, created_at. On LinkedIn email is
# None.
accounts = client.inbox.email_accounts()
own = [a for a in accounts.data if a["is_owner"]]

# Email threads with a CRM company (its domains), newest first.
# emails and domains take one address, a comma list or a list of strings.
threads = client.inbox.email_threads(
    record_id=company_id, record_type="company", is_read=False, limit=50
)
# threads.meta["accounts"] reports one entry per account you can see:
# id, name, email, is_owner, ok, error, count, where error is rate_limited,
# account_disconnected, forbidden and the like.
# A thread's preview lives inside last_message, beside who wrote it.
first = threads.data[0]
print(first["subject"], first["last_message"]["snippet"], first["account"]["is_owner"])

# Read one thread. account_id is required (the row's account id). order is
# "desc" (the default) or "asc", direction is "sent" or "received". An
# oldest-first read answers one page and no next_cursor.
detail = client.inbox.email_thread(
    first["id"], account_id=first["account"]["id"], order="asc"
)
# detail.data["has_limited_access"] is True when the account is shared for
# metadata only: body, text and both file lists come back empty.

# A message says who wrote it as an object, and carries the HTML body beside
# text, which is this message's own words with the quoted history removed.
msg = detail.data["messages"][0]
print(msg["from"]["name"], msg["from"]["email"], msg["text"])

# Download an attachment from a message (files embedded in the body are
# listed apart, under inline_images). Every file names its own message.
if msg["attachments"]:
    # raises LighthouseError over the 25 MB limit (413 attachment_too_large)
    content = client.inbox.download_email_attachment(
        msg["id"], msg["attachments"][0]["id"], account_id=msg["account"]["id"]
    )

# Read what an attachment SAYS instead of downloading it: PDFs, Word, Excel and
# PowerPoint files (and their OpenDocument versions), text, CSV and HTML. A long
# file comes back in windows: page with offset_chars while meta["has_more"] is
# true. When text is None, reason and note say why (an image, an older .doc or
# .xls, a scan with no text).
if msg["attachments"]:
    read = client.inbox.email_attachment_content(
        msg["id"], msg["attachments"][0]["id"], account_id=msg["account"]["id"]
    )
    print(read.data["kind"], read.data["text"] or read.data["note"], read.meta["has_more"])

# The same for a file kept in Documents.
# memo = client.documents.get_file_content(file_id)

# Draft a reply. The draft is saved in the mailbox, never sent, and comes back
# as an ordinary message with direction "draft". to, cc and bcc take an address,
# a comma list, a list or {"name", "email"} objects, so a message's own from or
# cc can be handed straight back. Write the message as text (plain) or as body
# (HTML), not both.
draft = client.inbox.create_email_draft(
    account_id=msg["account"]["id"],
    to=[msg["from"]],
    subject=f"Re: {msg['subject']}",
    text="Thanks for the note, following up shortly.",
    thread_id=detail.data["thread"]["id"],
    reply_to_message_id=msg["id"],
)

# LinkedIn: chats with a person, then one chat. is_read=False lists the chats
# that have unread messages; after and before read the chat's own date and both
# include the moment they name.
chats = client.inbox.linkedin_chats(
    person_linkedin="https://linkedin.com/in/username", is_read=False
)
chat_detail = client.inbox.linkedin_chat_messages(chats.data[0]["id"])
print([p["name"] for p in chat_detail.data["chat"]["participants"]])
print(chat_detail.data["messages"][0]["from"]["name"])

# Only the replies after a moment, oldest first. direction is "sent" or
# "received", order is "desc" (the default) or "asc". A date-only before
# includes that whole day. On a message the account sent, from is the account's
# own profile (connection_degree "self") and is_seen / is_delivered are the
# other side's receipts; both are None on a message it received.
replies = client.inbox.linkedin_chat_messages(
    chats.data[0]["id"],
    after="2024-03-01T09:30:00Z",
    direction="received",
    order="asc",
)

# Drafts, the LinkedIn twin of create_email_draft: the message you are still
# writing to a person, the same draft the Lighthouse app autosaves, one per
# LinkedIn account and person, and one per group chat. account_id is required
# (the id from linkedin_accounts). Name the conversation by chat_id (a group
# chat included), or the person by person_linkedin even when you have never
# messaged them: the draft then starts a new conversation (chat_id None),
# listed under Drafts in the Lighthouse inbox. A draft is id, chat_id,
# recipient, text, attachments, created_at, updated_at, updated_by, account.
# Nothing is ever sent from here: sending happens in the app, and a send clears
# the draft. Creating a draft where one exists replaces its text; files
# attached in the app stay.
draft = client.inbox.create_linkedin_draft(
    account_id=chats.data[0]["account"]["id"],
    person_linkedin="https://www.linkedin.com/in/username",
    text="Great to meet you last week. Could we find 20 minutes on Thursday?",
)
print(draft.data["id"], draft.data["chat_id"], draft.data["recipient"]["name"])

# Every draft you can read, most recently edited first, then discard one. A
# group chat's draft has recipient None. A chat also carries its draft
# (chat["draft"]), and linkedin_chats(has_draft=True) lists the chats with one.
drafts = client.inbox.linkedin_drafts(account_id=chats.data[0]["account"]["id"])
client.inbox.delete_linkedin_draft(drafts.data[0]["id"])

# Meetings on a connected calendar. meta["window"] is {"after", "before"}.
meetings = client.inbox.calendar_meetings(domains=["example.com"], upcoming=True)
print(meetings.data[0]["meeting_url"], meetings.data[0]["attendees"][0]["response_status"])

# One recorded meeting, then its notes and its transcript. meeting_key is the
# one handle that is the same for everybody who sat in the meeting, and a row
# carries it once the meeting was recorded.
key = next(m["meeting_key"] for m in meetings.data if m["recording"])
recorded = client.inbox.calendar_meeting(key)
if recorded.data["recording"]["has_notes"]:
    notes = client.inbox.calendar_meeting_notes(key)
    print(notes.data["content_markdown"], notes.data["is_edited"])

# meta is total_chars, offset_chars, returned_chars, has_more, next_offset_chars
transcript = client.inbox.calendar_meeting_transcript(key, offset_chars=0, max_chars=20000)
```

A recorded meeting's notes and transcript are returned only to a user who
recorded the meeting, was invited to it (as a guest, declined included, or as its
organizer), or had its meeting notes shared with them by a teammate who recorded it.
Each occurrence of a recurring meeting is judged on its own; anything else is not
found. A meeting note carries the same `meeting_key` in its `meeting` block, so a
user who can read the note can read its transcript (when `meeting.has_transcript` is
true) without access to the recorder's calendar. The recording itself is not
available through the API. A calendar account's `permissions` carry `calendar_view` (read its meetings)
and `meeting_notes_view` (read the notes of the meetings its owner recorded).

Three older parameter names are refused with a sentence rather than ignored, so
a filter can never be dropped in silence: use `emails` and `domains` in place of
`email` and `domain` on threads and meetings, and `is_read` in place of `unread`
on chats (`is_read=False` lists the chats that have unread messages).

### Network

Who can introduce you to a person, from what your team's LinkedIn networks
already say. This endpoint reads what is already known and never starts a new
search, so it is safe to call as often as you like. Searches are run from the
person's Network tab in Lighthouse.

```python
# Ask by CRM record, or by LinkedIn address for somebody who is not in your
# CRM. Send exactly one of record_id and linkedin: both together, or neither,
# is answered 400 invalid_input with a sentence naming the rule.
answer = client.network.intro_paths(record_id=person_id)

# Best paths first: the person who can make the introduction, the colleagues
# whose network found them, and how many connections they share with the
# person you want to reach. A path is person, location,
# shared_connections_count, found_via, record_id, searched_at.
for path in answer.data["paths"]:
    print(path["person"]["name"], [m["full_name"] for m in path["found_via"]],
          path["shared_connections_count"])
# connection_degree on a path is relative to the colleague who found them, not
# to you: "first" means a direct connection of that colleague, the warm intro.

# Why a path is missing is on the answer, so nothing has to be guessed.
# answer.data["is_searched"] False: nobody has looked for this person yet.
# is_searched True with answer.meta["total"] 0: a search found no shared
# connection.
# An account with is_searched False says why in not_searched_reason
# ("account_disconnected", "account_pending", "no_searches_left" or
# "not_searched_yet"), and searches_used / searches_limit say how much of this
# month is left on it. An account entry is id, name, email, is_owner,
# is_included, is_searched, searched_at, searches_used, searches_limit,
# not_searched_reason.
for account in answer.data["accounts"]:
    if not account["is_searched"]:
        print(account["name"], account["not_searched_reason"],
              account["searches_used"], account["searches_limit"])

# Narrow to certain accounts with account_ids: one id, a comma list or a list
# of the ids linkedin_accounts() returns. Every account you can see stays in
# accounts, and the ones you left out come back with is_included False. An id
# you cannot use is answered 404 not_found.
my_accounts = client.inbox.linkedin_accounts()
through_my_accounts = client.network.intro_paths(
    linkedin="https://www.linkedin.com/in/username",
    account_ids=[a["id"] for a in my_accounts.data if a["is_owner"]],
    limit=50,
)

# answer.meta is total, limit, offset, has_more, note. total counts the paths
# before paging. note is one sentence for a person to read, or None: branch on
# the keys, never on that text.
```

The answer covers your own LinkedIn accounts and the ones colleagues have
shared with you. A colleague who has not shared their network is not
represented at all, so a missing path can also mean a network this key cannot
see.

The person you asked about is `answer.data["person"]`, the LinkedIn person
shape the inbox uses (`name`, `linkedin`, `linkedin_url`, `avatar_url`,
`headline`, `connection_degree`), beside `record_id`,
`is_linkedin_connected`, `is_searched`, `searched_at` and `connected_members`,
the colleagues already connected to that person.

### Records

```python
# Create
created = client.records.create("company", data={
    "name": "Acme",
    "domain": ["acme.com"],
})
record_id = created.data["id"]

# Update: relation fields are replaced entirely, so pass the full desired array
client.records.update("company", record_id, data={"domain": ["acme.com", "acme.io"]})

# Get
client.records.get("company", record_id)

# Delete
client.records.delete("company", record_id)

# Upsert: find-or-create by a unique attribute. matching_value carries the
# value to match on, so it never has to be repeated inside data; leave it out
# to keep the older form, where the attribute inside data carries it.
client.records.upsert(
    "company", "domain", data={"name": "Acme"}, matching_value="acme.com"
)

# In a batch the attribute is one per call and the value rides on the item, as
# matching_value or as the attribute itself. The two forms mix.
client.records.bulk_upsert("person", "email", records=[
    {"matching_value": "jane@acme.com", "first_name": "Jane", "last_name": "Doe"},
    {"emails": ["sam@globex.com"], "first_name": "Sam", "last_name": "Rivera"},
])
```

### Filtering with attributes

```python
attrs = client.attributes.list(record_type="company").data
# Scope to a list to see the custom fields that belong to it. A list you
# cannot open answers 404 not_found, the same as one that does not exist.
scoped = client.attributes.list(record_type="company", list_id="…uuid…").data
# pick a field permalink from attrs, then:
client.records.search(
    "company",
    filters={
        "operator": "AND",
        "conditions": [{"field": "headcount", "operator": "gte", "value": 50}],
    },
    sort=[{"field": "name", "direction": "asc"}],
    limit=25,
)
```

`records.search` refuses a filter it cannot run as written with a 400 `invalid_input` whose message says what is wrong: an unknown field, an operator the field does not take (`list_entries` included, and on a linked record's field the operators of that field), a group joined by anything but `AND` or `OR`, or a value the field cannot take, such as a sequence name where sequence ids go. A `view_id`'s saved filter is checked the same way. On a dotted path only the operator is checked: a relation, or a linked record's field, that does not exist is neither refused nor reported, and that condition matches no record. `lists.get_records` reports an unknown field, an operator or a join it cannot use in `meta["ignored_filter_fields"]`, `meta["invalid_operators"]` and `meta["invalid_logical_operators"]` and leaves such a condition out (a group with such a join is read as `AND`), so read them after every filtered call; a value it cannot take is refused with a 400, as on `records.search`.

### Lists

```python
# Find a list by its name: only the lists whose name contains every word, in
# any case ("reviewed" finds "Deals Reviewed", never "Deals to Review").
# meta.total counts the matches. `query` is accepted as another name for `search`.
found = client.lists.search(search="deals reviewed")

new_list = client.lists.create(name="Hot leads", record_type="company")
list_id = new_list.data["id"]

# Adding a record that is already on the list, and removing one that is not,
# both succeed and write nothing. `reason` is what tells that apart from a real
# change: a sentence when nothing changed, None when something did. It is on
# the single writes and on every entry of the two bulk ones, where a failed
# entry carries the refusal in words there instead.
added = client.lists.add_record(list_id, record_id="…uuid…")
if added.data.get("reason"):
    print(added.data["reason"])  # "This record was already on the list."
client.lists.get_records(list_id, limit=50)
```

### Sharing views and dashboards

```python
client.views.update_sharing("…view_uuid…", "workspace", share_along=True)
client.views.share_with_teams("…view_uuid…", ["…team_uuid…"], share_along=True)

client.dashboards.update("…dashboard_uuid…", sharing="workspace", share_along=True)
client.dashboards.share_with_teams("…dashboard_uuid…", ["…team_uuid…"], share_along=True)
```

These four calls always answer `data["shared_along"]` (what the call shared along, empty when nothing was) and `data["audience_cannot_open"]`. `audience_cannot_open` lists the lists and sequences the view filters on, or the dashboard's widgets filter on, that some of the people it is now shared with cannot open, lists first (`type` is `list` or `sequence`). For those people a filter on such a list is not applied, and a filter on such a sequence counts it as a sequence nobody is in. Only the ones you can open are listed. An entry with `is_parent` true is the list a list view belongs to: people who cannot open it do not see the view at all.

`share_along` also shares the lists and sequences you own with the same people. Every one of them is shared along, so read `audience_cannot_open` first if you want to choose. A list is shared along only when your role can share lists, and a sequence only when it can share sequences. Nothing is shared along unless you pass `True`. `revoke_teams` never shares anything along and ignores the option.

### Notes

```python
client.notes.create(
    title="Intro call",
    content="<p>Met the founder, looking strong.</p>",
    records={"company": ["…uuid…"], "person": ["…uuid…"]},
    tags=["follow_up"],
)

# Manage the workspace tag vocabulary and attach tags to a note.
tag = client.note_tags.create("Follow up")
client.notes.add_tags("…note_uuid…", tags=[tag.data["permalink"]])
```

A note now reads back who it mentions: `tagged_users` is a list of the member
object (`{"id", "first_name", "last_name", "full_name", "avatar_url", "email"}`),
`[]` when it mentions nobody. It is read from the mention spans in `content`,
not from the ids a write sent, so pair every id in `tagged_users` with its span
(`<span data-type="mention" data-id="…" data-label="First Last">@First Last</span>`)
as the reference says, or the note reads back `[]`.

### Tasks

```python
client.tasks.create(
    title="Send follow-up",
    due_date="2026-06-01",
    assigned_to=["…user_uuid…"],
    records={"company": ["…uuid…"]},
)
```

`status` and `priority` answer the stored value and nothing else. The words
your workspace puts on those values come from `options.list()` for the `task`
record type, one call that covers every task.

`tasks.list()` filters and sorts on these fields: `title`, `status`,
`priority`, `due_date`, `created_at`, `updated_at`, `assigned_to`,
`created_by`, `records`, plus any custom task field permalinks. Status values
are workspace-specific (fetch the valid values with
`client.options.list("task", "status")`). Each option row is `{ value, label, color,
priority, is_default, enabled }`, the same object an attribute's
`options[]` carries; a built-in value the workspace switched off is listed
with `enabled: false`, so filter on `enabled` before offering one.

```python
client.tasks.list(
    filters={
        "operator": "AND",
        "conditions": [{"field": "status", "operator": "eq", "value": "to_do"}],
    },
    sort=[{"field": "due_date", "direction": "asc"}],
    limit=50,
)
```

### Discovery

```python
# Always inspect attributes first to discover valid field keys
client.discovery.get_attributes(record_type="company")

# Search Lighthouse's global database
res = client.discovery.search_companies(
    filters={
        "operator": "AND",
        "conditions": [
            {"field": "company_hq_country", "operator": "contains_any", "value": ["US"]},
            {"field": "company_headcount", "operator": "between", "value": {"min": 10, "max": 200}},
        ],
    },
    limit=25,
)

# Enrich by domain / LinkedIn
client.discovery.lookup_companies_by_domain(["stripe.com", "openai.com"])

# Find an entity by name (typo-tolerant, ranked by relevance then popularity).
# record_type is one of company, person, investor, school. Use a returned id in
# a filter, or pass it to a deep read. Pages with limit / offset and reports
# meta["has_more"].
matches = client.discovery.search_entities(
    record_type="company", query="relay robotics", limit=10
)

# Resolve a name straight to the single best match: the full profile for a
# company or a person, the identity row for an investor or a school. 404 when
# nothing matches, so prefer search_entities when the name is ambiguous.
company = client.discovery.get_entity_by_name(
    record_type="company", name="Relay Robotics"
)

# Semantic search with a plain-language query (optionally narrowed by filters)
res = client.discovery.semantic_search_companies(
    "climate fintech startups building carbon accounting software",
    filters={
        "operator": "AND",
        "conditions": [
            {"field": "company_hq_country", "operator": "contains_any", "value": ["US"]},
        ],
    },
    min_similarity=0.5,
    limit=25,
)
```

`search_investors`, `search_organizations` and `search_schools` are deprecated:
use `search_entities` with `record_type` `"investor"`, `"company"` or
`"school"` instead (typo-tolerant ranking, paging, and richer matches). They
keep working.

The shared vocabularies (`get_locations`, `get_industries`, `get_tags`,
`get_funding_types`) answer `value` (what a filter takes) beside `label` (what
to show someone). `name` carries the same string as `value` on funding types and
the same string as `label` on the other three; it is kept for compatibility, so
read `value` and `label`.

A discovery person's `associated_companies` and `education` entries carry `id`,
the company or school id `get_company` takes. `org_id` holds the same value and
is kept for compatibility.

Every discovery company and person row carries `crm_id`: the id of your own CRM
record for that entity, and `None` when it is not in your CRM. It is on the
search pages, the two lookups, the saved-search results and the full profiles
alike, so "which of these do we already track" is answered by the row you have
rather than by a search per result. Pass it to
`client.records.get("company", crm_id)` to open the record, and use
`client.records.upsert` on the rows where it is `None` to bring one in without
creating a duplicate. It is the same name, with the same meaning, that a CRM
record already uses when it points at the global database (a company's
`investors`, a person's `experience` and `education`).

### Documents

```python
# One-shot helper handles upload-url + the multipart POST upload
with open("pitch.pdf", "rb") as f:
    file = client.documents.upload(
        name="pitch.pdf",
        content=f.read(),
        content_type="application/pdf",
        sharing="workspace",
    )
file_id = file.data["id"]

# Attach to a record
client.documents.link_file_to_record(file_id, "company", "…uuid…")
```

### Webhook subscriptions

A webhook subscription makes Lighthouse send a signed `POST` to your `url` every time a record of `record_type` (`company`, `person`, `deal` or a custom object's permalink) is created or updated. Create one with an API key whose user's role has developer access and may view that record type. A subscription belongs to that user, and each user sees only their own.

```python
res = client.webhook_subscriptions.create(
    "https://example.com/lighthouse-webhooks",
    "company",
    events=["record.created", "record.updated"],
    headers={"X-Acme-Token": "a-value-of-your-own"},
    description="Company changes for our data warehouse",
)
sub = res.raise_for_error().data

# Shown once, and never returned again: keep it where your endpoint can read it.
# It is None when you sent your own signing_secret.
print(sub["signing_secret"])

subs = client.webhook_subscriptions.list().data
one = client.webhook_subscriptions.get(sub["id"]).data
print(one["status"], one["disabled_reason"], one["last_delivery"])

client.webhook_subscriptions.delete(sub["id"])
```

There is no update: to change a subscription, or to start again after it was turned off, delete it and create another. Reads return the names of your custom headers (`header_names`), never their values, and never the signing secret.

Verify every delivery before you trust it. `verify_webhook(secret, headers, raw_body, tolerance_seconds=300, now=None)` checks the `webhook-signature` header against the body exactly as it was received, refuses a `webhook-timestamp` more than `tolerance_seconds` from the current time, and returns the parsed event as a `dict`. It raises `WebhookVerificationError` (a `LighthouseError`) when a header is missing, the timestamp is out of range, no signature matches or the body is not JSON. Pass the raw body (`bytes` or `str`), never a parsed object: parsing and serialising JSON again changes the bytes that were signed. `headers` may be a mapping (a `dict`, or the headers object of Flask, Django or Starlette) or a list of name and value pairs; names are matched in any case. `now` takes a `datetime` or seconds since the epoch, for tests. Pass the signing secret exactly as you received it, `whsec_` included: the signing key is its text, never decoded, which is also why a Standard Webhooks library will not verify these deliveries.

```python
import os

from flask import Flask, request
from lighthouse import WebhookVerificationError, verify_webhook

app = Flask(__name__)


@app.post("/lighthouse-webhooks")
def lighthouse_webhooks():
    try:
        event = verify_webhook(
            os.environ["LIGHTHOUSE_WEBHOOK_SECRET"],
            request.headers,
            request.get_data(),
        )
    except WebhookVerificationError:
        return "", 400
    # event["id"] is the same on every retry of one delivery: use it to drop repeats.
    print(event["event"], event["record_type"], event["record_id"], event["changes"])
    return "", 204
```

Answer with any `2xx` quickly and do the work afterwards: a delivery that is not answered in time is retried.

## Error handling

```python
from lighthouse import Lighthouse, LighthouseError

client = Lighthouse("lgt_live_…")
try:
    res = client.records.search("company")
    res.raise_for_error()
    print(res.data)
except LighthouseError as e:
    print(f"API error ({e.status_code}):", e)
```

## Rate limits

A `429` with the code `rate_limit_exceeded` is retried for you, up to 4 times, before the client hands you the `429` exactly as the API sent it. Before each retry the client waits at least the seconds in the `Retry-After` header (1 second if there is none, at most 30). Each retry also draws a random wait that can grow with every attempt: up to 2 seconds before the first retry, then up to 4, 8 and 16 (never more than 30), and the client waits the longer of the two. Calls refused at the same moment therefore spread out instead of all coming back together and being refused again. The API refuses such a request before running it, so a retried `POST` is never applied twice. No other error is retried, `credit_limit_exceeded` included. `max_retries` sets the number of retries, and `0` turns retrying off:

```python
client = Lighthouse("lgt_live_...", max_retries=0)
```

## Low-level escape hatch

For endpoints not yet wrapped by a namespace:

```python
res = client.request("GET", "/v1/some/new/endpoint", params={"foo": "bar"})
res = client.request("POST", "/v1/some/other", json={"hello": "world"})
```

## License

MIT, see [LICENSE](LICENSE).
