Webhooks
Register an HTTPS endpoint and Pika posts a signed event as soon as a media job finishes, so you don’t have to poll for it. Pika signs webhooks following the Standard Webhooks specification, so any library that implements it can verify our deliveries.
Register an endpoint
up to 16 per organization
You add endpoints on the Webhooks page in this console. Owners and admins can add, edit, and delete them. Any member can view them. A new endpoint gets every event type, including ones we add later. Set an event filter if you only want some of them.
- Use HTTPS on port 443. The URL has to be reachable from the public internet.
- pika.art hosts aren’t accepted.
- Answer 2xx right away. Do the slow work after you’ve replied.
- We don’t follow redirects, so register the final URL.
| Event | Sent when |
|---|---|
| media.job.completed | A job finished and its output is ready. |
| media.job.failed | A job failed. Look at data.error for the code. |
Send test on an endpoint’s page posts a signed webhook.test event, so you can check the URL, the signature, and your handler without running a job. Every endpoint receives it, whatever its event filter says, and it makes a single attempt with no retries.
Per-request webhooks
one callback, one job
Any media submit takes an optional webhook_url. Pika posts that one job’s terminal event there, so a script can get a callback without registering anything first.
POST https://api.dev.pika.art/v1/media/pika/pika-2.5/text-to-videoauthorization: Bearer $PIKA_API_KEYcontent-type: application/json{"prompt": "a paper boat crossing a puddle","webhook_url": "https://example.com/pika/webhooks"}
These deliveries are signed with your organization’s per-request secret rather than an endpoint’s. Reveal it on the Webhooks page in this console, or read it from GET /v1/webhooks/secret. Everything else is the same: the envelope, the webhook-* headers, the Standard Webhooks verification, and the retry schedule.
- The URL has to pass the same checks as a registered one: HTTPS on port 443, reachable from the public internet, not a pika.art host, no credentials.
- A delivery can reach you before the submit response does. Match it to the job with data.id and dedupe 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 you don’t have to poll for the failure.
What a delivery looks like
POST · application/json
POST https://example.com/pika/webhookscontent-type: application/jsonuser-agent: pika-webhooks/1.0webhook-id: msg_0f1e4b7a-9c33-4d81-b0a2-6e5d8c7f41abwebhook-timestamp: 1786650067webhook-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}}
- webhook-id identifies the event. Retries and replays reuse it, so if you’ve seen an id before, you already have the event.
- webhook-timestamp is the unix time of this attempt, not of the event.
- data is the same job object that GET /v1/media/jobs/{request_id} returns once the job is done.
- runtime_s is best effort. It can be null.
Verify the signature
before you trust the payload
Give your Standard Webhooks library the raw request bytes and the three webhook-* headers. Use the raw bytes, not a re-serialized copy of the JSON, or the signature won’t match.
from fastapi import FastAPI, HTTPException, Requestfrom standardwebhooks import Webhook, WebhookVerificationErrorapp = 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}
- Libraries reject a timestamp more than five minutes off. That stops someone from replaying a delivery they captured earlier.
- While a secret is rotating, the header holds more than one signature. Matching any of them is valid, and libraries already handle that.
- If verification fails, answer 4xx and stop. Don’t read the body.
Verifying without a library
The signed content is the exact bytes {webhook-id}.{webhook-timestamp}.{raw body}. To get the key, remove whsec_ from the secret and base64-decode the rest. Take the HMAC-SHA256 of that content with the key, then base64-encode it.
The webhook-signature header holds one or more v1,<signature> values separated by spaces. Compare yours with each one using a constant-time comparison, and accept the delivery if any matches. That is what lets you rotate secrets without rejecting deliveries. Also reject the delivery if webhook-timestamp falls outside your tolerance. Five minutes is the convention, and it stops someone from replaying a captured request later.
import base64import hashlibimport hmacimport timedef verify(secret, body, msg_id, timestamp, signature_header):if abs(time.time() - int(timestamp)) > 300:raise ValueError("timestamp outside tolerance")key = base64.b64decode(secret.removeprefix("whsec_"))signed = f"{msg_id}.{timestamp}.".encode() + bodyexpected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()for part in signature_header.split(" "):version, _, signature = part.partition(",")if version == "v1" and hmac.compare_digest(expected, signature):returnraise ValueError("no matching signature")
Retries
9 attempts over about 55 hours
A delivery succeeds when your endpoint answers with any 2xx within 20 seconds. Anything else is a failed attempt, and we try again.
- 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 header in seconds pushes the next attempt back, up to 1 hour. It can’t make us retry sooner.
- If you answer 410 Gone, we disable the endpoint. It shows as Disabled (gone), and you can turn it back on from its page.
- After the last attempt, the delivery is marked failed. You can replay it from the endpoint’s page.
- Every attempt reuses the same webhook-id, so dedupe on it.
- A webhook.test event is the exception: it makes one attempt and never retries.
When an attempt fails, we record why. You can see recent deliveries and every attempt on the endpoint’s page in this console.
| Error | Meaning |
|---|---|
| timeout | No response within 20 seconds. |
| connect | We couldn’t open a connection. |
| blocked | The address was refused at egress. It’s private or not allowed. |
| tls | The TLS handshake or certificate check failed. |
| http_4xx: <code> | Your endpoint answered with a 4xx. |
| http_5xx: <code> | Your endpoint answered with a 5xx. |
| signing | We couldn’t sign the delivery. We retry at no cost to you. |
Secrets and rotation
rotate without dropping events
Each endpoint has its own signing secret, and per-request deliveries share one organization-wide secret. Both are base64 with a whsec_ prefix. Reveal an endpoint’s from its page and the organization’s from the Webhooks page, and rotate either the same way: you pick how long the old secret stays valid, immediately, one hour, or one day.
During that overlap, both secrets sign every delivery, so webhook-signature holds two values separated by a space. Either signature is valid, so you can switch over without dropping an event.
webhook-signature: v1,LWSb0oRhsMhAaMLpQGXjNL0aOEmMPuTv0AUKZBFPEUE= v1,tQ9nS2pKcW4Hs1dRz7yVbXm0gJ3FfN8uLoEiA6TrCxU=