# Webhooks (https://docs.propertypixel.app/webhooks)

Get notified when jobs finish and uploads arrive.



Add an endpoint in **Settings → Developers → Webhooks** (workspace owners only). Choose the events you want and copy the signing secret. It's shown once; you can rotate it later.

## Events [#events]

| Event              | When                                                            |
| ------------------ | --------------------------------------------------------------- |
| `job.completed`    | A photo edit (enhancement or staging) finished.                 |
| `job.failed`       | A job failed. Its credits were refunded.                        |
| `video.completed`  | A Photo to Video or Listing Tour video finished.                |
| `upload.completed` | Photos arrived through an upload link and the user tapped Done. |

## Payload [#payload]

Every delivery is a `POST` with a JSON body:

```json
{
  "type": "job.completed",
  "timestamp": "2026-10-05T10:15:00.000Z",
  "data": {
    "id": "9b1c…",
    "kind": "enhancement",
    "status": "completed",
    "project_id": "2f4e…",
    "photo_ids": ["a81d…"],
    "enhancement_types": ["improve_lighting"],
    "result": { "url": "https://…", "expires_at": "2026-10-06T10:15:00.000Z" }
  }
}
```

`data` for job events follows the [Job](/docs/api) shape, and its result URL lasts 24 hours. Events sent with **Send test event** include `"test": true`.

## Verifying signatures [#verifying-signatures]

Deliveries are signed with the [Standard Webhooks](https://www.standardwebhooks.com) scheme, using the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. Verify against the **raw** request body.

```ts title="Node"
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.PROPERTYPIXEL_WEBHOOK_SECRET!); // "whsec_…"

export async function POST(request: Request) {
  const body = await request.text();
  const event = wh.verify(body, Object.fromEntries(request.headers)); // throws if invalid
  // handle event.type …
  return new Response(null, { status: 204 });
}
```

```python title="Python"
from standardwebhooks import Webhook

wh = Webhook(os.environ["PROPERTYPIXEL_WEBHOOK_SECRET"])
event = wh.verify(request.body, dict(request.headers))  # raises if invalid
```

Use `webhook-id` to deduplicate: retries reuse the same ID.

## Retries and disabling [#retries-and-disabling]

* Respond with any `2xx` within 10 seconds to acknowledge.
* Failed deliveries are retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (8 attempts over about 21 hours).
* After 8 deliveries in a row exhaust their retries, or after 3 days of failures, the endpoint is disabled and the workspace owner is emailed. Re-enable it in Settings once it's fixed.
* The delivery log keeps 30 days of attempts, and you can resend any delivery.
