# Webhook

Fires a webhook every time a task is passed as input. Returns the same task as output.

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2FW3pAtA5QskGgWoQhHZvl%2Fimage.png?alt=media&#x26;token=e343ba0d-b93f-42f5-9a63-22c8215e19c7" alt=""><figcaption></figcaption></figure>

## Settings

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2FQgwoVSp6VXesf1PTlw7J%2Fimage.png?alt=media&#x26;token=5151c7ca-acfc-4409-b375-8b1bfa5776a2" alt="" width="375"><figcaption></figcaption></figure>

**URL.** The url where to send the webhook.\
**Auth Type.** The authentication method you'd like to use. Please see [this section below](#authentication-methods) for more information.\
**Logs.** Whenever a task enters the *Webhook* stage and a webhook is fired or an error occurs, this is logged. Clicking on *Logs* allows you to scrutinize logs for all webhooks, with the format: `[Date][Status] external_id`

## Authentication Methods

### No Auth

A standard POST request to your URL with the JSON event payload.

On your server, you may accept the request without verification.

#### Server Code Examples

<details>

<summary>Flask</summary>

```python
from flask import Flask, request, Response
app = Flask(__name__)

@app.route("/hook", methods=["POST"])
def hook():
    # No auth — just process the payload
    event = request.get_json(force=True, silent=True)
    if event is None:
        return Response("Invalid JSON", status=400)
    # ... handle event ...
    return "ok", 200
```

</details>

### Secret (X-Hub-Signature)

#### What we send

* A POST request containing the raw body.
* Header: X-Hub-Signature: `<hex-encoded HMAC-SHA1 of the raw request body using your shared secret>`

{% hint style="info" %}
Note: The header value is the plain hex digest (no `sha1=` prefix).
{% endhint %}

#### How verification works

1. Read the raw request body bytes.
2. Compute `HMAC-SHA1(secret, body)` and hex-encode it.
3. Compare the computed digest to the `X-Hub-Signature` header using a constant-time comparison.
4. Reject if they don’t match.

#### Server Code Examples

<details>

<summary>Flask</summary>

```python
import json, hmac, hashlib
from flask import Flask, request, Response

secret = b"your_secret_key"
app = Flask(__name__)

@app.route("/hook", methods=["POST"])
def hook():
    computed = hmac.new(secret, msg=request.data, digestmod=hashlib.sha1).hexdigest()
    provided = request.headers.get("X-Hub-Signature", "")
    # Constant-time compare is recommended:
    if not hmac.compare_digest(provided, computed):
        return Response("Invalid signature", status=400)

    print(json.dumps(request.get_json(), indent=2))
    return "ok", 200
```

</details>

<details>

<summary>Node/Express</summary>

```javascript
import express from "express";
import crypto from "crypto";

const app = express();
// Use raw body to avoid re-serialization differences
app.post("/hook", express.raw({ type: "*/*" }), (req, res) => {
  const secret = Buffer.from(process.env.WEBHOOK_SECRET || "your_secret_key");
  const expected = crypto.createHmac("sha1", secret).update(req.body).digest("hex");
  const provided = (req.get("X-Hub-Signature") || "");

  // timingSafeEqual requires equal-length buffers
  const a = Buffer.from(provided, "utf8");
  const b = Buffer.from(expected, "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(400).send("Invalid signature");
  }

  // ... handle event ...
  res.send("ok");
});

app.listen(3000);
```

</details>

{% hint style="info" %}
Things to note:

* Compute the HMAC over the exact raw bytes received, not parsed/re-serialized JSON.
* Use constant-time comparison (`hmac.compare_digest, timingSafeEqual`) to avoid timing attacks.
* If you proxy/transform requests, ensure the body is unchanged before verification.
  {% endhint %}

### Bearer Token

#### What we send

* A POST request containing the event payload.
* Header: `Authorization: Bearer <your-configured-token>`

#### How verification works

1. Read the Authorization header.
2. Check it equals `Bearer <token-you-configured>`.
3. Reject if missing or mismatched.

#### Server Code Examples

<details>

<summary>Flask</summary>

```python
import os
from flask import Flask, request, Response

TOKEN = os.getenv("WEBHOOK_TOKEN", "your_token")
app = Flask(__name__)

@app.route("/hook", methods=["POST"])
def hook():
    auth = request.headers.get("Authorization", "")
    expected = f"Bearer {TOKEN}"
    if auth != expected:
        return Response("Unauthorized", status=401)

    # ... handle event ...
    return "ok", 200
```

</details>

<details>

<summary>Node/Express</summary>

```javascript
import express from "express";

const app = express();
app.use(express.json());

app.post("/hook", (req, res) => {
  const token = process.env.WEBHOOK_TOKEN || "your_token";
  const provided = req.get("Authorization") || "";
  const expected = `Bearer ${token}`;

  // Constant-time compare where possible
  if (provided !== expected) {
    return res.status(401).send("Unauthorized");
  }

  // ... handle event ...
  res.send("ok");
});

app.listen(3000);
```

</details>

## General Recommendations

* Respond with a 2xx status once you’ve accepted the event; do heavy work asynchronously. This helps webhooks flow.
* Log the event ID and timestamp (if present) for idempotency/retries.
* Keep your secret/token out of source control and rotate regularly.

## Webhook Content

### Sample Webhook Output

<details>

<summary>Sample Webhook Output</summary>

```json
{
  "projectId": "67a3be4088cb6f0f7976327b",
  "categorySchema": {
    "tools": [
      {
        "title": "",
        "tool": "bounding-box",
        "required": false,
        "schemaId": "d3fb304c505050a0631e201",
        "ocrEnabled": false,
        "classifications": [],
        "multiple": false,
        "color": "#f44336",
        "shortcutKey": "1"
      }
    ],
    "classifications": [],
    "relations": []
  },
  "asset": "https://angohub-test-assets.s3.eu-central-1.amazonaws.com/67a3be4088cb6f0f7976327b/assets/9a02ef5e-1281-48a6-902e-c959c1e1b201.png",
  "assetId": "67a3beaf88cb6f0f797634ff",
  "dataset": [],
  "overlay": [],
  "externalId": "task5.png",
  "metadata": {
    "width": 1001,
    "height": 1001
  },
  "batches": [
    "67a3beb088cb6f0f79763515",
    "67a3beb088cb6f0f79763516"
  ],
  "batchNames": [
    "batch-1",
    "batch-2"
  ],
  "task": {
    "type": "default",
    "taskId": "67a3beb088cb6f0f79763514",
    "stage": "08960214-cb68-4109-ac75-33ed521e9cac",
    "stageId": "08960214-cb68-4109-ac75-33ed521e9cac",
    "stageName": "Webhook",
    "updatedAt": "2025-02-06T10:13:28.709Z",
    "updatedBy": "lorenzo@imerit.net",
    "duration": 0,
    "blurDuration": 0,
    "idleDuration": 0,
    "totalDuration": 3549,
    "totalBlurDuration": 0,
    "totalIdleDuration": 0,
    "tools": [
      {
        "bounding-box": {
          "x": 401.2628984836038,
          "y": 398.68154506437764,
          "height": 200.2,
          "width": 201.91845493562232
        },
        "objectId": "49c2c2d71cccba94e471625",
        "classifications": [],
        "metadata": {
          "createdAt": "2025-02-06T10:13:27.625Z",
          "createdBy": "lorenzo@imerit.net",
          "createdIn": "Label"
        },
        "schemaId": "d3fb304c505050a0631e201",
        "title": ""
      }
    ],
    "classifications": [],
    "relations": [],
    "stageHistory": [
      {
        "stageId": "Start",
        "duration": 0,
        "idleDuration": 0,
        "blurDuration": 0,
        "completedAt": "2025-02-05T19:40:32.305Z",
        "isSkipped": false,
        "_id": "67a3beb088cb6f0f79763577"
      },
      {
        "stageId": "Label",
        "duration": 3549,
        "idleDuration": 0,
        "blurDuration": 0,
        "completedAt": "2025-02-06T10:13:28.745Z",
        "taskHistory": "67a48b48a31195645b7a257b",
        "completedBy": "lorenzo@imerit.net",
        "submittedBy": "lorenzo@imerit.net",
        "isSkipped": false,
        "_id": "67a48b48a31195645b7a257d"
      },
      {
        "stageId": "fd4b3696-96ef-49aa-8e2d-c545db740f61",
        "duration": 0,
        "idleDuration": 0,
        "blurDuration": 0,
        "completedAt": "2025-02-06T10:14:08.803Z",
        "taskHistory": "67a48b70a31195645b7a2acf",
        "consensusId": null,
        "isSkipped": false,
        "_id": "67a48b70a31195645b7a2ae1"
      }
    ],
    "brushDataUrl": null,
    "medicalBrushDataUrl": null
  }
}
```

</details>

### Differences between Webhook Output and Export

<table><thead><tr><th>Export</th><th>Webhook</th></tr></thead><tbody><tr><td><p>The <code>batches</code> property provides batches as names.</p><pre class="language-json"><code class="lang-json"> "batches": [
    "Le Croissànt"
  ]
</code></pre></td><td><p>The <code>batches</code> property provides batches as IDs.</p><pre class="language-json"><code class="lang-json"> "batches": [
    "651521e299f4bf0015872c91"
  ]
</code></pre><p>In the webhook output, batch names are provided in the <code>batchNames</code> property.</p></td></tr><tr><td><code>stageHistory</code> field contains label contents.</td><td><code>stageHistory</code> field only contains metadata, no label contents.</td></tr></tbody></table>

## Webhook Errors

In case the webhook cannot be sent (e.g. Ango Hub does not receive a 200 response), Ango Hub will keep the task in the Webhook stage and display a visual warning in the Workflow editor:

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2F6xphvXB3gWJmE90Ndr94%2Fimage.png?alt=media&#x26;token=1d7dc46e-8299-47c2-ba62-73a15b4b91ff" alt=""><figcaption></figcaption></figure>

To view the tasks the webhooks of which were not sent, navigate to the *Tasks* tab and filter by stage from the left-hand side.

To attempt to send the webhook again, click on the Webhook stage, then click on the three dots on the top right of its settings panel, and click on *Re-run.*

## Set Up a Sample Webhook Server (X-Hub-Signature Auth Method)

This sample is a minimum server setup you can use to test whether your webhook configuration is working or not.

1. Run this Python script, changing `your_secret_key` with a secret key of your choice.

<details>

<summary>Sample Webhook Python Script</summary>

```python
import json
from flask import request, Flask, Response
import hmac
import hashlib

secret = b'your_secret_key'  # Secret Key you entered while adding the integration
app = Flask(__name__)

@app.route('/hook', methods=['POST'])  # Custom Endpoint
def hook():
    computed_signature = hmac.new(secret, msg=request.data, digestmod=hashlib.sha1).hexdigest()
    if request.headers["X-Hub-Signature"] != computed_signature:
        return Response("Invalid signature", status=400)
    else:
        print(json.dumps(request.get_json(), indent=2))
        return "ok"

if __name__ == '__main__':
    app.run(debug=True)
```

</details>

2. Install ngrok on your system. Instructions on installing ngrok can be found [here](https://ngrok.com/download).
3. Once ngrok is installed, from the command line/terminal, run `ngrok http 127.0.0.1:5000`
4. You will see a screen like the following. Copy the URL highlighted in red.

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2F21Btx3kBl02X1WEs4fwz%2Fimage.png?alt=media&#x26;token=4684b178-ccf7-4432-ab5c-47d629936b1b" alt=""><figcaption></figcaption></figure>

5. Go to your Ango Hub project and set up your workflow to have a Webhook stage plugged in. In this case, for example, the Webhook stage will fire every time a labeler submits a task in the *Label* stage:

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2FUbjq5jPN37G3543ejHmR%2Fimage.png?alt=media&#x26;token=ed3f0cb5-8fb0-4e0e-9792-3f49af8b0e91" alt=""><figcaption></figcaption></figure>

6. Click on the Webhook plugin to open its settings.
7. In the *URL* field, paste the URL we copied before, adding `/hook` at the end. For example, if the URL provided by ngrok was `https://47f2-88-243-68-208.ngrok.io`,  you will paste it and add `/hook` at the end, forming `https://47f2-88-243-68-208.ngrok.io/hook`.
8. In the *Secret* field, type the secret key you entered in the Python script during step 1.
9. Save your workflow.
10. In your project, perform an action which would trigger a webhook. In our example above, it would be submitting a tasl from the *Label* stage.

If the webhook worked correctly, you will see a `200 OK` code in the ngrok window:

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2FayYWDyxjLYfRpJoIHI6Z%2Fimage.png?alt=media&#x26;token=20bfec9f-b9cd-48cc-abb6-7a295c62c21c" alt=""><figcaption></figcaption></figure>

And the webhook content will be sent to your server where you ran the Python script. If you ran it in PyCharm, for example, you will see the webhook contents in the *Run* tab:

<figure><img src="https://3895963154-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FTcOUG6rfWxqGM0N4db2P%2Fuploads%2FvYINUxqnZperxcCU0wrS%2Fimage.png?alt=media&#x26;token=ae53c446-b6dd-4422-9d34-788d131e7d24" alt=""><figcaption></figcaption></figure>
