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

# Percify API webhooks: payloads and signature headers

> Pass a webhook URL when you start a Percify API job and Percify POSTs the result when it ends, with X-Percify-Timestamp and X-Percify-Signature headers.

A Percify webhook is a single HTTPS POST that Percify sends to your server when an API job succeeds or fails. You turn it on per job by passing a `webhook` URL when you start it; there is no account-wide webhook setting. Each POST carries the job's result as JSON plus `X-Percify-Timestamp` and `X-Percify-Signature` headers.

## Which calls accept a webhook?

| Start call                                                                              | Field                 | Sent when                             |
| --------------------------------------------------------------------------------------- | --------------------- | ------------------------------------- |
| [`POST /v3/playground/v1/run`](/api-reference/generations/run)                          | `webhook` in the body | The generation succeeds or fails      |
| [`POST /v3/replicate/v1/analyses/{id}/replicate`](/api-reference/video-studio/overview) | `webhook` in the body | The replication run is done or failed |
| [`POST /v3/mascot/api`](/api-reference/short-videos)                                    | `webhook` in the body | The short video is done or failed     |

The MCP tools `generate`, `replicate_video` and `make_video` take the same `webhook` argument.

## URL requirements

* It must be a valid URL that starts with `https://`.
* The host must resolve to a public IP address. `localhost`, `.local` and `.internal` hosts and private network addresses are refused.
* Percify checks the URL when you start the job, so a bad URL fails the start call with a `400` such as `webhook must use https`.

To test locally, expose your server through a public HTTPS tunnel.

## What Percify sends

Every webhook is a `POST` with these headers:

| Header                | Value                                                               |
| --------------------- | ------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                  |
| `User-Agent`          | `Percify-Webhook/1`                                                 |
| `X-Percify-Timestamp` | Unix time in seconds when the POST was signed                       |
| `X-Percify-Signature` | `sha256=` followed by a hex HMAC-SHA256 of `{timestamp}.{raw body}` |

### Generation payload

```json theme={"system"}
{
  "id": "3f6c2a9e-8b1d-4c57-9a0e-2d4b7f1c6e83",
  "modelId": "infinitetalk-fast",
  "status": "succeeded",
  "output": {
    "type": "video",
    "urls": ["https://cdn.percify.io/media-assets/playground/3f6c2a9e-8b1d-4c57-9a0e-2d4b7f1c6e83-0"]
  },
  "error": null
}
```

A failed generation has `"status": "failed"`, `"output": null` and the reason in `error`. Its credits were already refunded.

### Replication payload

The same fields as [`GET /v3/replicate/v1/runs/{id}`](/api-reference/video-studio/overview#get-a-replication-run), plus `"event": "replication.completed"`. Check `status` for `done` or `failed`.

### Short video payload

```json theme={"system"}
{ "jobId": "7a1e4c2b-0d9f-4b8e-a3c5-5f2e9d1b6a70", "status": "done", "videoUrl": "https://cdn.percify.io/…", "creditsSpent": 68 }
```

A failed job sends `{ "jobId": "…", "status": "failed", "error": "…" }`.

## Delivery rules

* **One attempt.** Percify sends each webhook once and does not retry. If your server is down, recover with the polling endpoint.
* **Your response is not read.** Return `200` quickly and do slow work afterwards.
* **No polling needed.** Percify checks running jobs in the background, so the webhook is sent even if you never poll.

## Verify a webhook

The signature is an HMAC-SHA256 of the timestamp, a dot and the exact raw request body, keyed with a signing secret for your account. The developer console does not show that secret, so you cannot check `X-Percify-Signature` yourself today.

Treat the webhook as a notification and confirm it with your API key instead: take the `id` from the body and read the job from Percify. Anyone can POST to your URL, but only Percify can answer `GET /v1/generations/{id}` for your key.

<CodeGroup>
  ```javascript Node.js (Express) theme={"system"}
  import express from "express";

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

  app.post("/percify/webhook", async (req, res) => {
    res.sendStatus(200); // answer fast; Percify does not retry

    const { id } = req.body ?? {};
    if (typeof id !== "string") return;

    const r = await fetch(`https://api.percify.io/v3/playground/v1/generations/${id}`, {
      headers: { Authorization: `Bearer ${process.env.PERCIFY_API_TOKEN}` },
    });
    if (!r.ok) return; // not a generation from your account

    const { data } = await r.json();
    if (data.status === "succeeded") {
      // save data.output.urls
    }
  });

  app.listen(3000);
  ```

  ```python Python (Flask) theme={"system"}
  import os, requests
  from flask import Flask, request

  app = Flask(__name__)

  @app.post("/percify/webhook")
  def percify_webhook():
      gen_id = (request.get_json(silent=True) or {}).get("id")
      if isinstance(gen_id, str):
          r = requests.get(
              f"https://api.percify.io/v3/playground/v1/generations/{gen_id}",
              headers={"Authorization": f"Bearer {os.environ['PERCIFY_API_TOKEN']}"},
              timeout=15,
          )
          if r.ok:
              data = r.json()["data"]
              if data["status"] == "succeeded":
                  pass  # save data["output"]["urls"]
      return "", 200
  ```
</CodeGroup>

For replication runs, confirm with `GET /v3/replicate/v1/runs/{id}`. For short videos, use `GET /v3/mascot/api/{jobId}`.

## Tips

* Store the job id when you start it, and ignore webhooks for ids you do not know.
* Make your handler safe to run twice for the same id.
* Keep a slow fallback poll for jobs that never reported back, for example if your server was down when the POST arrived.

## Related

<CardGroup cols={2}>
  <Card title="Async jobs and polling" href="/api-reference/async-jobs">
    Statuses, timeouts and refunds.
  </Card>

  <Card title="Start a generation" href="/api-reference/generations/run">
    Where the webhook field goes.
  </Card>

  <Card title="Get a generation" href="/api-reference/generations/get">
    Confirm a webhook with your key.
  </Card>

  <Card title="Code examples" href="/guides/sdk-integration">
    Node.js, Python and cURL end to end.
  </Card>
</CardGroup>
