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

# Webhooks

> Get a signed POST when a computer connects or disconnects, and when a task starts, needs a person, gets an answer or ends.

Instead of polling, give GuidingHand an HTTPS endpoint. It sends a signed `POST` when something changes in your org's sessions and tasks. Each org has one endpoint.

## Set the endpoint

Set it in the console (**Settings → Webhooks**), or with `PUT /v1/webhook`. Leave `events` empty to get every type, or list the ones you want; an unknown type is a `400`. Only the fields you send change: leave out `url` to keep the endpoint, leave out `events` to keep the filter, and send `{ "url": null }` to remove the endpoint. It needs the admin role or an API key.

```bash theme={null}
curl -X PUT https://guidinghand.ai/v1/webhook \
  -H "Authorization: Bearer $GUIDINGHAND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/guidinghand", "events": ["session.connected", "task.waiting_for_user", "task.question_answered", "task.waiting_for_approval", "task.approval_decided", "task.completed", "task.failed", "task.stopped"]}'
```

```json Response theme={null}
{
  "object": "webhook",
  "url": "https://example.com/guidinghand",
  "events": ["session.connected", "task.waiting_for_user", "task.question_answered", "task.waiting_for_approval", "task.approval_decided", "task.completed", "task.failed", "task.stopped"],
  "has_secret": true,
  "event_types": ["session.connected", "session.disconnected", "task.started", "task.waiting_for_user", "task.question_answered", "task.waiting_for_approval", "task.approval_decided", "task.completed", "task.failed", "task.stopped"],
  "secret": "whsec_..."
}
```

<Warning>
  `secret` is returned only when it is first made, or when you send `"rotate_secret": true`. Store it right away. `GET /v1/webhook` shows `has_secret` but never the secret itself. Changing the URL or events later keeps the same secret.
</Warning>

* To rotate the secret, send `{ "rotate_secret": true }` (the URL and events stay as they are). The old secret stops working at once, so deploy the new one to your receiver first or accept both for a moment.
* To remove the endpoint, send `{"url": null}`.
* `GET /v1/webhook` returns the current `url`, `events`, `has_secret` and the list of `event_types`.

### Public HTTPS only

The URL must use `https` and resolve to a public address. Private and local addresses (`10.x`, `127.x`, `172.16–31.x`, `192.168.x`, `localhost`, `*.local`, `*.internal` and the like) are refused with `400 invalid_request`, and the check runs again on every delivery. Redirects are not followed. For local development, expose your receiver through a tunnel with a public HTTPS URL.

## Event types

| `type`                      | Sent when                                                                                                                                                               | `data` has         |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `session.connected`         | A computer connects with the session's code, including reconnects.                                                                                                      | `session`          |
| `session.disconnected`      | The session's computer goes offline.                                                                                                                                    | `session`          |
| `task.started`              | A task starts running.                                                                                                                                                  | `task`             |
| `task.waiting_for_user`     | The agent asked a question. Answer `task.pending` with `/respond`, or, when `task.pending.customer_can_answer` is `true`, the customer may answer it on their screen.   | `task`             |
| `task.question_answered`    | A question was answered, by your team or by the customer on their screen. The first answer is the one used.                                                             | `task`, `answer`   |
| `task.waiting_for_approval` | The agent needs an approval. Decide `task.pending` with `/respond`, or, when `task.pending.customer_can_approve` is `true`, the customer may decide it on their screen. | `task`             |
| `task.approval_decided`     | An approval was granted or denied, by your team or by the customer on their screen. The first decision is the one used.                                                 | `task`, `decision` |
| `task.completed`            | The task finished. `task.result` has the summary.                                                                                                                       | `task`             |
| `task.failed`               | The task failed. `task.error` has the reason.                                                                                                                           | `task`             |
| `task.stopped`              | The task was stopped by you or the customer.                                                                                                                            | `task`             |

A task that asks several questions sends `task.waiting_for_user` and `task.question_answered` for each, and likewise `task.waiting_for_approval` and `task.approval_decided` for each approval.

`task.question_answered` tells you what the answer was and who gave it: `answered_by` is `customer` (the person at the computer, in the GuidingHand app) or `operator` (your team, through the API or the console). Use it to close the question in your own tool when the customer answered it first. `data.task` is the task just after the answer, so its `pending` is `null`.

```json theme={null}
{
  "id": "evt_Q3m1Xk0aVb7sT2Lc",
  "type": "task.question_answered",
  "created_at": "2026-09-26T15:05:48.217Z",
  "org_id": "org_42lu9lx6oea0",
  "data": {
    "task": {
      "object": "task",
      "task_id": "task_mW8mcFPUN7Of",
      "session_id": "K7QM-24XP",
      "agent_id": "billing",
      "status": "running",
      "done": false,
      "prompt": "Turn on Dark Mode",
      "result": null,
      "error": null,
      "pending": null,
      "cursor": 5,
      "metadata": { "ticket_id": "T-1042" },
      "created_at": "2026-09-26T15:05:37.559Z",
      "updated_at": "2026-09-26T15:05:48.215Z",
      "active_seconds": 4,
      "billed_minutes": null,
      "replay_url": "https://guidinghand.ai/console/acme/tasks/task_mW8mcFPUN7Of"
    },
    "answer": { "question_id": "q_yrJaMMY1ZMM", "answer": "Work", "answered_by": "customer" }
  }
}
```

`task.approval_decided` does the same for approvals. `data.decision` has the `approval_id`, the `decision` (`approve` or `deny`), your team's `note` (or `null`) and `answered_by`:

```json theme={null}
"decision": { "approval_id": "appr_OZ0pj8lNbZk", "decision": "approve", "note": null, "answered_by": "customer" }
```

## Payload

Every event has the same envelope. `data.task` and `data.session` are the same objects the API returns.

```json theme={null}
{
  "id": "evt_piKxpY_-wYmLl6ZG",
  "type": "task.completed",
  "created_at": "2026-09-26T15:06:52.110Z",
  "org_id": "org_42lu9lx6oea0",
  "data": {
    "task": {
      "object": "task",
      "task_id": "task_mW8mcFPUN7Of",
      "session_id": "K7QM-24XP",
      "agent_id": "billing",
      "status": "completed",
      "done": true,
      "prompt": "Turn on Dark Mode",
      "result": "Dark Mode is on. I switched Appearance to Dark in System Settings.",
      "error": null,
      "pending": null,
      "cursor": 14,
      "metadata": { "ticket_id": "T-1042" },
      "created_at": "2026-09-26T15:05:37.559Z",
      "updated_at": "2026-09-26T15:06:52.106Z",
      "active_seconds": 41,
      "billed_minutes": 1,
      "replay_url": "https://guidinghand.ai/console/acme/tasks/task_mW8mcFPUN7Of"
    }
  }
}
```

Use `metadata` to find your ticket, and `id` to ignore an event you've already handled.

## Verify the signature

Every request has a `GuidingHand-Signature` header:

```text theme={null}
GuidingHand-Signature: t=1790434012,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is the Unix time the request was signed. `v1` is the hex HMAC-SHA256 of `"{t}.{raw body}"`, keyed with your endpoint's whole secret (including the `whsec_` prefix). To verify:

1. Read the **raw** request body, before any JSON parsing. Re-serialized JSON won't match.
2. Compute the HMAC of `t`, a `.`, and the raw body with your secret.
3. Compare it with `v1` in constant time.
4. Reject the request if `t` is more than 5 minutes from your clock. This stops replays of old requests.

Answer `2xx` quickly and do slow work afterwards, so the delivery doesn't time out.

<CodeGroup>
  ```javascript Node theme={null}
  // node webhook.mjs (no dependencies)
  import http from 'node:http';
  import crypto from 'node:crypto';

  const SECRET = process.env.GUIDINGHAND_WEBHOOK_SECRET; // whsec_…
  const TOLERANCE_S = 300; // 5 minutes

  function verify(rawBody, header, secret) {
    const parts = String(header ?? '').split(',').map((p) => p.split('='));
    const t = parts.find(([k]) => k === 't')?.[1];
    const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
    if (!/^\d+$/.test(t ?? '') || sigs.length === 0) return false;
    if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;
    const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
    return sigs.some((s) => {
      const got = Buffer.from(s, 'hex');
      return got.length === expected.length && crypto.timingSafeEqual(got, expected);
    });
  }

  http.createServer((req, res) => {
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => {
      const raw = Buffer.concat(chunks);
      if (!verify(raw, req.headers['guidinghand-signature'], SECRET)) {
        res.writeHead(400).end('bad signature');
        return;
      }
      res.writeHead(200).end('ok');

      const event = JSON.parse(raw);
      if (event.type === 'task.waiting_for_user') {
        const { task } = event.data;
        console.log(`Task ${task.task_id} asks: ${task.pending.question}`);
      }
      if (event.type === 'task.question_answered' && event.data.answer.answered_by === 'customer') {
        console.log(`The customer answered on their screen: ${event.data.answer.answer}`);
      }
      if (event.type === 'task.approval_decided' && event.data.decision.answered_by === 'customer') {
        console.log(`The customer chose to ${event.data.decision.decision} on their screen`);
      }
    });
  }).listen(3000);
  ```

  ```python Python theme={null}
  # pip install flask && flask --app webhook run --port 3000
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, abort, request

  SECRET = os.environ["GUIDINGHAND_WEBHOOK_SECRET"]  # whsec_…
  TOLERANCE_S = 300  # 5 minutes


  def verify(raw: bytes, header: str, secret: str) -> bool:
      parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p]
      t = next((v for k, v in parts if k == "t"), "")
      sigs = [v for k, v in parts if k == "v1"]
      if not t.isdigit() or not sigs:
          return False
      if abs(time.time() - int(t)) > TOLERANCE_S:
          return False
      expected = hmac.new(secret.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
      return any(hmac.compare_digest(expected.encode(), s.encode()) for s in sigs)


  app = Flask(__name__)


  @app.post("/guidinghand")
  def guidinghand():
      raw = request.get_data()
      if not verify(raw, request.headers.get("GuidingHand-Signature"), SECRET):
          abort(400)

      event = request.get_json()
      if event["type"] == "task.waiting_for_user":
          task = event["data"]["task"]
          print(f"Task {task['task_id']} asks: {task['pending']['question']}")
      if event["type"] == "task.question_answered" and event["data"]["answer"]["answered_by"] == "customer":
          print(f"The customer answered on their screen: {event['data']['answer']['answer']}")
      if event["type"] == "task.approval_decided" and event["data"]["decision"]["answered_by"] == "customer":
          print(f"The customer chose to {event['data']['decision']['decision']} on their screen")
      return "ok", 200
  ```
</CodeGroup>

## Delivery and retries

* Each delivery waits up to 10 seconds for your answer. Any `2xx` counts as received.
* On a timeout, a network error or any other status (including `3xx`), GuidingHand retries up to 4 times, after about 5 seconds, 20 seconds, 1 minute and 2 minutes: about 3 minutes in total. Then it gives up on that event.
* A retry sends the same body with the same `id`, and a fresh `t` and signature.
* Events can arrive out of order, and more than once. Use `id` to skip duplicates, and trust the `status` and `updated_at` inside `data` rather than the order of arrival. When in doubt, `GET` the task or session.

Webhooks are a signal, not the record. If your endpoint was down for longer than the retries, the API still has everything: list recent tasks with `GET /v1/tasks` to catch up.
