> For the complete documentation index, see [llms.txt](https://docs.projectsim.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.projectsim.ai/guides/webhooks.md).

# Webhooks

Receive HTTPS notifications when an asset, variation set, or batch changes, then use the API to retrieve its current status and download URLs. Webhooks are optional; you can keep using the polling workflow in the [Quickstart](/getting-started/quickstart.md).

## Register an endpoint

Your receiver must be reachable at a public HTTPS URL. Register it with `POST /v1/webhooks` and choose the events listed below. Registration returns `201` immediately, with a webhook ID and a signing secret. It does not return a generation job.

Install the Python SDK and set `PROJECTSIM_TOKEN` as described in the [Quickstart](/getting-started/quickstart.md). Replace the example URL with your receiver URL before running either example.

{% tabs %}
{% tab title="Python" %}

```python
import os

import httpx
from projectsim import AuthenticatedClient
from projectsim.api.webhooks import create_webhook
from projectsim.models import CreateWebhookRequest

client = AuthenticatedClient(
    base_url="https://api.projectsim.ai",
    token=os.environ["PROJECTSIM_TOKEN"],
    timeout=httpx.Timeout(30.0),
    raise_on_unexpected_status=True,
)
with client:
    response = create_webhook.sync_detailed(
        client=client,
        body=CreateWebhookRequest(
            url="https://partner.example.com/hooks/projectsim",
            events=["asset.completed", "asset.failed"],
        ),
    )
if response.status_code != 201:
    raise RuntimeError(response.content.decode("utf-8"))
created = response.parsed
if created is None:
    raise RuntimeError("The API returned an empty response")
webhook_id = created.id
signing_secret = created.secret
# Store signing_secret securely now; it is returned only once.
print(webhook_id)
```

{% endtab %}

{% tab title="cURL" %}

```bash
curl -X POST "https://api.projectsim.ai/v1/webhooks" \
  -H "Authorization: Bearer $PROJECTSIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://partner.example.com/hooks/projectsim",
    "events": ["asset.completed", "asset.failed"]
  }'
```

{% endtab %}
{% endtabs %}

Store `secret` from the response in your receiver's secret storage. It is returned **only at creation**, never by the list endpoint. Treat it separately from your API token. The cURL response includes the secret; avoid sharing or logging it. Registration does not support an `Idempotency-Key`: list existing webhooks before retrying an uncertain registration to avoid duplicates.

## Events

| Event                     | Fires when                                                                                                                                           |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `asset.completed`         | The asset is ready to download.                                                                                                                      |
| `asset.failed`            | The job ended in `failed`, including a timeout. Retrieve the job for error details.                                                                  |
| `asset.canceled`          | The job was canceled before it started generating.                                                                                                   |
| `variation_set.completed` | The set finished, in `complete` or `partial`. Read the per-member counts on the set.                                                                 |
| `variation_set.failed`    | The set itself failed, such as a generation failure or timeout. Members failed by that transition also emit `asset.failed`; inspect member outcomes. |
| `variation_set.canceled`  | The set was canceled before any member started. Its members fire `asset.canceled` too.                                                               |
| `batch.completed`         | A batch finishes in `complete` or `partial`; inspect member outcomes.                                                                                |
| `batch.failed`            | No members succeeded, or the batch itself failed.                                                                                                    |
| `batch.canceled`          | The batch was canceled or all its members were canceled.                                                                                             |

Subscribe to the individual event names you need from the table above.

Real event payloads include `event`, `occurred_at`, and a `job` object with `id`, `resource` (`type` and `id`), `status`, `current_stage`, and `progress`. The top-level `id` identifies the delivery, while `job.id` identifies the job. Use `job.resource.id` to retrieve the asset, variation set, or batch, or `job.id` to retrieve the job and its error details. Download URLs are retrieved from the resource, not included in the event.

## Verify every delivery

Each request is a JSON `POST` with these headers:

| Header                   | Meaning                                                                                   |
| ------------------------ | ----------------------------------------------------------------------------------------- |
| `X-ProjectSim-Signature` | `t=<timestamp>,v1=<hmac>`; authenticate the request with your signing secret.             |
| `X-ProjectSim-Delivery`  | Stable delivery ID, also present as `id` in the JSON body. Use it to deduplicate retries. |
| `X-ProjectSim-Event`     | Event name, also present as `event` in the JSON body.                                     |

Verify the HMAC-SHA256 signature over `"{t}." + raw_request_body` **before processing the event**. Use the original bytes, not parsed and re-serialized JSON. The example below rejects malformed signatures and timestamps more than five minutes away from the receiver's clock, matching the server's verifier. Keep the receiver's clock synchronized.

```python
import hashlib
import hmac
import time


def verify(secret: str, header: str, raw_body: bytes, now: int | None = None) -> bool:
    parts = dict(
        piece.split("=", 1) for piece in header.split(",") if "=" in piece
    )
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - timestamp) > 300:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    expected = f"t={timestamp},v1={digest}"
    return hmac.compare_digest(expected.encode(), header.encode())
```

Pass your stored secret, the `X-ProjectSim-Signature` header, and the raw HTTP body to `verify`. Reject the request if it returns `False`. After verification, persist the event and its delivery ID once, then return `2xx` promptly. Process long-running work separately. If a previously accepted delivery arrives again, return `2xx` without repeating its side effects. Timestamp validation and persistent deduplication serve different purposes; use both.

## Test and inspect deliveries

After registration, call `POST /v1/webhooks/{webhook_id}/test`. It returns `202` with a `delivery_id` and `status: pending`; the worker sends the signed request asynchronously. This response confirms queuing, not successful receipt.

The receiver gets a synthetic payload of this shape, signed the same way as a real event. Its `job` contains synthetic IDs that refer to no real resource; check `test` before trying to fetch or act on them:

```json
{
  "id": "whd_01J8FQZ3K9V2M4X7WPB6NTRHCE",
  "event": "test",
  "test": true,
  "occurred_at": "2026-09-22T12:00:00+00:00",
  "job": {
    "id": "job_01J8FQZ3K9V2M4X7WPB6NTRHCE",
    "resource": {"type": "asset", "id": "asset_01J8FQZ3K9V2M4X7WPB6NTRHCE"},
    "status": "complete",
    "current_stage": "physics",
    "progress": 1.0
  }
}
```

{% tabs %}
{% tab title="Python" %}

```python
from projectsim.api.webhooks import list_deliveries, test_webhook

# Reuse webhook_id from registration. Open a fresh SDK client connection.
client = AuthenticatedClient(
    base_url="https://api.projectsim.ai",
    token=os.environ["PROJECTSIM_TOKEN"],
    timeout=httpx.Timeout(30.0),
    raise_on_unexpected_status=True,
)
with client:
    queued = test_webhook.sync_detailed(webhook_id=webhook_id, client=client)
    if queued.status_code != 202 or queued.parsed is None:
        raise RuntimeError(queued.content.decode("utf-8"))
    print(queued.parsed.to_dict())

    history = list_deliveries.sync_detailed(
        webhook_id=webhook_id, client=client, limit=20,
    )
    if history.status_code != 200 or history.parsed is None:
        raise RuntimeError(history.content.decode("utf-8"))
    print(history.parsed.to_dict())
```

{% endtab %}

{% tab title="cURL" %}
Set `WEBHOOK_ID` to the ID returned at registration, then queue a test:

```bash
curl -X POST "https://api.projectsim.ai/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $PROJECTSIM_TOKEN"
```

Inspect delivery history, repeating this GET after the worker has sent it:

```bash
curl "https://api.projectsim.ai/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20" \
  -H "Authorization: Bearer $PROJECTSIM_TOKEN"
```

{% endtab %}
{% endtabs %}

Delivery history is newest first. Each entry reports `status`, `attempts`, `last_response_code`, `next_retry_at`, and `delivered_at`. Status is `pending` until a successful response or retries are exhausted, then `delivered` or `dead`. `last_response_code: null` means no HTTP response was recorded. Use `next_cursor` as the next request's `cursor` to paginate; null ends the list.

## Retries and managing endpoints

* Return `2xx` within 10 seconds. Other responses and connection failures retry.
* Redirects are not followed. A `3xx` counts as a failed attempt and shows up as `last_response_code`, so register the final URL rather than one that redirects to it.
* Requests arrive with `User-Agent: ProjectSim-Webhooks/1.0` and `Content-Type: application/json`.
* Retry delays start at 30 seconds, double up to six hours, and failures are marked `dead` after about 24 hours. Delivery is at-least-once.
* Use `GET /v1/webhooks` to list registered endpoints; it never returns secrets.
* Use `DELETE /v1/webhooks/{webhook_id}` to unregister an endpoint (`204`). Queued deliveries stop; an already in-flight request may still arrive.
* There is no update endpoint. Register a replacement URL or subscription, store its new secret, and delete the old webhook.

See the [API reference](/api-reference/api-reference.md) for all five webhook operations. Poll the relevant asset, job, variation set, or batch when you need its latest state and fresh download URLs.
