# Pika Webhooks

> Pika posts a signed HTTPS callback when a media job reaches a terminal state, so an integration does not have to poll the job endpoint. Deliveries follow the Standard Webhooks specification, so any library implementing it verifies them without Pika-specific code.

Use this file to build a receiver. Endpoints themselves are registered and inspected in the [console](https://dev.pika.art/webhooks).

- Signature scheme: [Standard Webhooks](https://www.standardwebhooks.com/)
- Subscribable events: `media.job.completed`, `media.job.failed`
- Endpoints per organization: 16
- Delivery timeout: 20 seconds
- Retries: 9 attempts over about 55 hours
- Human guide: https://dev.pika.art/models/webhooks
- API index: https://dev.pika.art/llms.txt

## Polling or webhooks

A media submit returns a job with `status: "queued"`. You can poll `GET https://api.dev.pika.art/v1/media/jobs/{request_id}` until the job reaches a terminal state, or let Pika tell you: register a webhook endpoint, or pass a `webhook_url` on the submit itself. The `data` object on a delivery is the same job object polling returns, so both paths parse the same shape.

Deliveries are not ordered. Treat each one as independent and match it to a job with `data.id`.

## Register an endpoint

Endpoints are created on the [Webhooks page](https://dev.pika.art/webhooks) in the console. Owners and admins can add, edit, and delete them; any member can view them. An organization may register up to 16.

The receiving URL has to satisfy all of the following, or registration is rejected:

- HTTPS on port 443, reachable from the public internet.
- Not a `pika.art` host.
- Not a private or otherwise non-global address.
- No credentials in the URL.

A new endpoint subscribes to every event type, including ones added later. Setting an event filter narrows it to the listed types, so a new event type will not reach a filtered endpoint until it is added to the filter.

## Per-request webhooks

Any media submit takes an optional top-level `webhook_url`. Pika posts that one job's terminal event there, so a script gets a callback without registering an endpoint first. The URL has to satisfy the same rules as a registered one, and a rejected one answers `400`.

```http
POST https://api.dev.pika.art/v1/media/pika/pika-2.5/text-to-video
authorization: Bearer $PIKA_API_KEY
content-type: application/json

{
  "prompt": "a paper boat crossing a puddle",
  "webhook_url": "https://example.com/pika/webhooks"
}
```

- These deliveries are signed with an organization-wide secret rather than an endpoint's. Read it from `GET https://api.dev.pika.art/v1/webhooks/secret`, which mints it on first read, or reveal it on the [Webhooks page](https://dev.pika.art/webhooks). Owners and admins only.
- `POST https://api.dev.pika.art/v1/webhooks/secret/rotate` takes `{"expire_in_seconds": <seconds>}` and answers like an endpoint's rotation, overlap included.
- Verification is unchanged: same envelope, same `webhook-*` headers, same Standard Webhooks libraries.
- A delivery can arrive before the submit response does. Match it to the job with `data.id` and deduplicate on `webhook-id`.
- Reusing an idempotency key with a different `webhook_url` is a `409`.
- A job that fails sends `media.job.failed` to the same URL, so a failure needs no polling either.

## Events

| Event | Sent when |
| --- | --- |
| `media.job.completed` | A job finished and its output is ready. |
| `media.job.failed` | A job failed. Branch on `data.error.code`. |
| `webhook.test` | Sent only by Send test in the console. Every endpoint receives it regardless of its filter, and it is not subscribable. |

`webhook.test` makes a single attempt and never retries. Use it to check the URL, the signature path, and the handler without running a job.

## What a delivery looks like

```http
POST https://example.com/pika/webhooks
content-type: application/json
user-agent: pika-webhooks/1.0
webhook-id: msg_0f1e4b7a-9c33-4d81-b0a2-6e5d8c7f41ab
webhook-timestamp: 1786650067
webhook-signature: v1,LWSb0oRhsMhAaMLpQGXjNL0aOEmMPuTv0AUKZBFPEUE=

{
  "type": "media.job.completed",
  "timestamp": "2026-08-14T09:41:07.482913+00:00",
  "data": {
    "id": "media_8f3a2c91-5b7d-4e0a-9c26-31d4f2a8e6b0",
    "status": "completed",
    "model_id": "pika/pika-2.5/text-to-video",
    "runtime_s": 42.7,
    "output": {
      "media_type": "video",
      "video": {
        "url": "https://api.dev.pika.art/v1/files/output.mp4",
        "content_type": "video/mp4"
      }
    },
    "error": null
  }
}
```

| Header | Meaning |
| --- | --- |
| `webhook-id` | Identifies the event. Retries and replays reuse it, so treat a seen id as already handled. |
| `webhook-timestamp` | Unix time of this attempt, not of the event. |
| `webhook-signature` | One or more space-separated `v1,<base64>` values. Matching any one is valid. |

The envelope carries `type` (the event type), `timestamp` (ISO 8601, when the event occurred), and `data` (the job object). `data.runtime_s` is best effort and can be `null`. On `media.job.failed`, `data.error.code` is the stable machine-readable value to branch on and `data.error.message` is diagnostic text that varies.

## Verify the signature

Verify before trusting a payload, and pass the **raw request bytes**. A re-serialized copy of the parsed JSON will not match. Give the bytes and the three `webhook-*` headers to a Standard Webhooks library.

```python
from fastapi import FastAPI, HTTPException, Request
from standardwebhooks import Webhook, WebhookVerificationError

app = FastAPI()
webhook = Webhook(PIKA_WEBHOOK_SECRET)


@app.post("/pika/webhooks")
async def receive(request: Request) -> dict:
    try:
        event = webhook.verify(await request.body(), dict(request.headers))
    except WebhookVerificationError:
        raise HTTPException(status_code=400, detail="invalid signature")

    handle(event["type"], event["data"])
    return {"ok": True}
```

```typescript
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.PIKA_WEBHOOK_SECRET);
const rawJson = express.raw({ type: "application/json" });

app.post("/pika/webhooks", rawJson, (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.status(400).send("invalid signature");
  }

  handle(event.type, event.data);
  res.sendStatus(200);
});
```

Go and Java receivers follow the same shape; the guide at https://dev.pika.art/models/webhooks carries both.

Rules that hold whichever library you use:

- Reject a `webhook-timestamp` outside a five-minute tolerance. Libraries do this already; it is what stops a captured delivery from being replayed later.
- `webhook-signature` can hold more than one signature during a secret rotation. Accept the delivery if any of them matches.
- On a verification failure, answer `4xx` and stop. Do not parse the body.

### Verifying without a library

The signed content is the exact bytes `{webhook-id}.{webhook-timestamp}.{raw body}`. Strip the `whsec_` prefix from the secret and base64-decode the rest to get the key. Take the HMAC-SHA256 of the signed content with that key and base64-encode the digest.

Split `webhook-signature` on spaces, and for each `v1,<signature>` part compare your digest against `<signature>` with a constant-time comparison. Any match accepts the delivery.

## Retries

A delivery succeeds when the endpoint answers any `2xx` within 20 seconds. Anything else counts as a failed attempt. Answer first and do the slow work afterwards, or the handler will time out.

- Attempts are spaced roughly 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, and 24 hours apart, with jitter.
- A `Retry-After` response header in seconds pushes the next attempt back by up to 1 hour. It cannot pull an attempt earlier.
- Answering `410 Gone` disables the endpoint. It reads as `Disabled (gone)` in the console and can be re-enabled there.
- Redirects are not followed. Register the final URL.
- After the last attempt the delivery is marked `failed`, and can be replayed from the console.
- Every attempt and every replay reuses the same `webhook-id`. Deduplicate on it; handlers must be idempotent.

Deliveries carry a status of `pending`, `delivered`, `failed`, or `canceled`. When an attempt fails, Pika records why:

| Error | Meaning |
| --- | --- |
| `timeout` | No response within 20 seconds. |
| `connect` | The connection could not be opened. |
| `blocked` | The address was refused at egress: private or not allowed. |
| `tls` | The TLS handshake or certificate check failed. |
| `http_4xx: <code>` | The endpoint answered 4xx. |
| `http_5xx: <code>` | The endpoint answered 5xx. |
| `signing` | Pika could not sign the delivery. Retried at no cost. |

## Secrets and rotation

There are two kinds: each endpoint has its own signing secret, and per-request deliveries share one organization-wide secret. Both are base64 behind a `whsec_` prefix — an endpoint's is revealed from its page in the console, the organization's from the Webhooks page or `GET /v1/webhooks/secret`. Store either as a server-side secret, conventionally named `PIKA_WEBHOOK_SECRET`, and never in browser code or source control.

Rotation takes an overlap: immediately, one hour, or one day (86400 seconds, the maximum and the default). During the overlap both secrets sign every delivery, so `webhook-signature` holds two space-separated values and either one verifies. That is what lets a receiver switch secrets without dropping an event.

```http
webhook-signature: v1,LWSb0oRhsMhAaMLpQGXjNL0aOEmMPuTv0AUKZBFPEUE= v1,tQ9nS2pKcW4Hs1dRz7yVbXm0gJ3FfN8uLoEiA6TrCxU=
```

## Receiver checklist

1. Register the HTTPS URL in the console and copy the signing secret into the receiver's environment.
2. Read the raw body before any JSON middleware parses it.
3. Verify the signature and timestamp; answer `4xx` and stop if either fails.
4. Deduplicate on `webhook-id`, then answer `2xx` immediately.
5. Process the event after replying, branching on `type` and, for failures, on `data.error.code`.
6. Send a test event from the endpoint's page and confirm the attempt is recorded as delivered.

---

Guide: https://dev.pika.art/models/webhooks
Console: https://dev.pika.art/webhooks
API index: https://dev.pika.art/llms.txt
