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

# Receive search results by webhook

> Orbit calls your server as each person's profile is built, so you never have to ask whether a search is done yet.

A search takes as long as the profiles take to build. Polling `GET /v3/search/{search_id}` suits a page someone is watching; a background job is better served by a push.

Orbit sends each message as soon as it commits the change, so you hear about a person whether or not you poll.

Register a URL once and Orbit `POST`s to it every time a result moves forward, carrying that person's whole profile — the same object a profile read returns. One final delivery closes the search out. By the end of this guide you will have that running end to end.

You need an API key with the `search:read`, `profile:read`, and `webhooks:write` scopes ([get one here](/api/api-keys/create)).

<Steps>
  <Step title="Get a public URL for your server">
    Orbit has to be able to reach your server over the public internet, so
    `http://localhost:3000` will be rejected — Orbit refuses `http://` URLs and
    anything pointing at `localhost`, `.local`, or a private network address.

    On a deployed server you already have a public URL, so skip ahead. While
    building on your laptop, use a tunnel — [ngrok](https://ngrok.com) is the
    common one:

    ```bash theme={"dark"}
    ngrok http 3000
    ```

    That prints a public `https://` address that forwards to your local port.
    Copy it; you'll use it in the next step and it stays valid while ngrok runs.
  </Step>

  <Step title="Tell Orbit where to send things">
    Register your URL, and say which messages you want.

    ```bash theme={"dark"}
    curl -X POST "https://api.orbitsearch.com/v3/webhooks" \
      -H "Authorization: Bearer $ORBIT_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://your-public-url.example.com/orbit/webhooks",
        "event_types": ["search.profile.updated", "search.completed"]
      }'
    ```

    The response contains a `secret` that starts with `whsec_`. **Save it now
    — it is shown once and never again.** Put it in your environment as
    `ORBIT_WEBHOOK_SECRET`. It is what proves an incoming request really came
    from Orbit rather than from someone who guessed your URL.

    Also keep the `id` from the response — that's your `WEBHOOK_ID` for the
    test step below.

    <Accordion title="If you get an error here">
      * `webhook_url_invalid` — the URL must start with `https://`.
      * `webhook_url_not_public` — Orbit could not reach it. A `localhost`
        address, a private IP, or a tunnel that has stopped running.
    </Accordion>
  </Step>

  <Step title="Write the handler">
    Here is a complete, working server. Copy it as-is and it runs — the only
    thing you need to change is what you do with the data at the bottom.

    ```javascript theme={"dark"}
    import express from "express";
    import { createHmac, timingSafeEqual } from "node:crypto";

    const app = express();
    const secret = process.env.ORBIT_WEBHOOK_SECRET;
    const seen = new Set(); // Remembers which messages we've handled already.

    // Checks the message really came from Orbit and was not tampered with.
    function isFromOrbit(headers, rawBody) {
      const timestamp = headers["x-orbit-webhook-timestamp"];
      const signature = headers["x-orbit-webhook-signature"] || "";
      // Reject anything older than 5 minutes, so an old message cannot be replayed.
      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
      const expected = `sha256=${createHmac("sha256", secret)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex")}`;
      const a = Buffer.from(expected);
      const b = Buffer.from(signature);
      return a.length === b.length && timingSafeEqual(a, b);
    }

    // express.raw, not express.json: the signature is checked against the exact
    // bytes Orbit sent, so the body must not be parsed before we verify it.
    app.post("/orbit/webhooks", express.raw({ type: "application/json" }), (req, res) => {
      if (!isFromOrbit(req.headers, req.body)) return res.sendStatus(401);

      // Say "got it" immediately. Orbit gives you 10 seconds, and any real work
      // belongs after this line, not before it.
      res.sendStatus(200);

      // Orbit retries on failure, so the same message can arrive twice. Handle
      // each one once. (In production, use your database rather than a Set.)
      const id = req.headers["x-orbit-webhook-event-id"];
      if (seen.has(id)) return;
      seen.add(id);

      const event = JSON.parse(req.body.toString());

      if (event.type === "search.profile.updated") {
        const { search_id, result } = event.data;
        console.log(`[${search_id}] ${result.profile_id} is now ${result.status}`);
        // result.profile is the whole person. Do your thing with it here:
        console.log(result.profile?.displayName, result.profile?.headline);
      }

      if (event.type === "search.completed") {
        console.log(`Search ${event.data.search_id} finished: ${event.data.status}`);
      }
    });

    app.listen(3000, () => console.log("Listening on :3000"));
    ```
  </Step>

  <Step title="Check it works, before running a real search">
    Send yourself a fake message. This posts one straight away, signed exactly
    like a real one, and tells you what your server replied.

    ```bash theme={"dark"}
    curl -X POST "https://api.orbitsearch.com/v3/webhooks/$WEBHOOK_ID/test" \
      -H "Authorization: Bearer $ORBIT_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"event_type": "search.profile.updated"}'
    ```

    Look for `"delivered": true` in the response and a line in your server's
    console. Test messages carry `"test": true` and use made-up data, so they
    are safe to fire as often as you like — see
    [Send a test delivery](/api/webhooks/test).

    <Accordion title="If delivered is false">
      The `error` field tells you what happened. `http_401` means your
      signature check rejected it — usually the wrong `ORBIT_WEBHOOK_SECRET`,
      or `express.json` parsing the body before the check. `http_404` means
      the path in your registered URL does not match your route. A connection
      error means Orbit could not reach the URL at all.
    </Accordion>
  </Step>

  <Step title="Run a search">
    Add `"webhooks": true` to any search and the messages start flowing.

    ```bash theme={"dark"}
    curl -X POST "https://api.orbitsearch.com/v3/search" \
      -H "Authorization: Bearer $ORBIT_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "query": "Machine learning engineers at Example Labs near San Francisco",
        "candidate_discovery": true,
        "profile_depth": "full",
        "include_profile": false,
        "webhooks": true
      }'
    ```

    That returns straight away with a `search_id`. You do not need to do
    anything else with it — watch your console instead.

    **Send `include_profile: false` when you use webhooks.** The messages carry
    each full profile regardless, so you lose nothing, and it keeps both the
    immediate response and Orbit's own work small. With `include_profile: true`
    Orbit rebuilds every result's profile each time any one of them changes,
    which on a large search is work neither side needs.
  </Step>
</Steps>

## What arrives, and when

Every message looks like this:

```json theme={"dark"}
{
  "id": "unique-id-for-this-message",
  "type": "search.profile.updated",
  "created": "1789765924",
  "data": { }
}
```

`data` is where everything lives — the full field list is on [Webhook events](/api/webhooks/events).

A message goes out whenever Orbit saves something new for a person: the first profile it publishes for them, each web-search segment that adds sections, and the photos that land with a save. That usually means two or three messages per person:

| Their `status` | What it means                                                                        |
| -------------- | ------------------------------------------------------------------------------------ |
| `generating`   | Orbit found this person and started building. No profile yet.                        |
| `enriching`    | A profile exists and is readable, still filling in.                                  |
| `ready`        | Done, at the depth you asked for.                                                    |
| `failed`       | This one could not be built. `result.failure` says why. Other people are unaffected. |

Then one `search.completed` at the very end. Use `result.profile_id` as your key and overwrite that person's row each time a message about them arrives.

## How many people you hear about

One search's messages cover its **first 100 people**, in the order the search found them. That order never changes between polls, so the same 100 people are covered from start to finish.

A search that finds more than 100 is not truncated — only the messages are. Read the rest with `GET /v3/search/{search_id}`, which returns every result as it always has. The final `search.completed` message tells you when this applied:

```json theme={"dark"}
{
  "result_count": 150,
  "ready_count": 148,
  "failed_count": 2,
  "results_truncated": true,
  "results_limit": 100
}
```

The counts describe the whole search. `results` lists the people the messages covered. A search inside the cap carries neither `results_truncated` nor `results_limit`.

The cap exists because every message carries a whole profile. Without it, one large search could push far more data at your server than it asked for.

## Things worth knowing

* **Messages can arrive twice.** If your server is slow or returns an error, Orbit retries — up to 6 times, spacing the attempts further apart each time. Retries reuse the same `x-orbit-webhook-event-id`, which is why the example keeps a `seen` set.
* **Reply fast.** Answer `200` within 10 seconds and do the real work afterwards, as the example does. A handler that does slow work before replying will time out and get retried unnecessarily.
* **Always check the signature.** Your URL is reachable by anyone who learns it. The check in the example is the thing that makes an incoming message trustworthy.
* **Order is not guaranteed.** Retries can land out of order. `data.occurred_at` tells you when each change actually happened — compare it before overwriting newer data with older.
* **If your server is down for a while**, Orbit keeps retrying through the backoff window. After 20 failures in a row an endpoint is switched off; register a new one to start receiving again. An endpoint receives the messages that happen after you register it, so a search still in progress carries on reaching you — including the people it had already finished — while a search that had already ended needs a rerun.
* **You can send it to Slack.** Register a Slack incoming-webhook URL instead and you get a readable message in a channel rather than JSON. Handy for a notifications channel alongside your real handler.

## Costs

Nothing extra. The search costs what it always costs — per person, at the depth reached. Messages are free, and a person who generates three of them costs the same as one who generates none.

One setting does change how hard Orbit works, though not what you pay: send `include_profile: false`, as the step above does. A search that also asks for profiles in its HTTP response rebuilds all of them whenever any one person changes.

## Turning it off

Only searches sent with `"webhooks": true` produce messages, so leaving the field out is enough for a one-off. To stop entirely, [delete the endpoint](/api/webhooks/delete).

This also means one API key can serve both an app where someone is watching a page (poll those, no `webhooks` field) and a background pipeline (push those). Polling still works on a search that pushes, so you can run both while you migrate.
