XPlor3
REST API

Webhooks

XPlor3 tells your server when a job succeeds, fails or is cancelled, so you don't have to keep asking.

Reading GET /v1/jobs/{id} every few seconds works, but for a video that takes minutes it is dozens of requests with the same answer. With a webhook you name an address when you start the job, and when the job ends XPlor3 sends one signed POST to it.

Get the signing secret

Open Credentials in the studio, pick the REST API tab, and press Show in the Webhook section. Keep the value (it starts with whsec_) as an environment variable on your server, for example XPLOR3_WEBHOOK_SECRET.

The secret can only be read and rotated from the studio, never with an API key. One account has one secret for all of its webhooks.

Send webhookUrl when you start the job

Add webhookUrl next to modelSlug and input:

curl https://api.xplor3.space/v1/jobs \
  -H "Authorization: Bearer $XPLOR3_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "modelSlug": "nano-banana-2",
    "input": { "prompt": "A cup of coffee on a wooden table by the window, morning light" },
    "webhookUrl": "https://example.com/hooks/xplor3"
  }'

An address that does not meet the requirements is refused with 400 WEBHOOK_URL_INVALID before any credits move.

Receive it, check the signature, answer 2xx

Check the signature against the raw body, answer 2xx quickly, and do the rest in the background. Examples are in Checking the signature.

The message

Every message is a POST with a JSON body and these headers:

HeaderWhat it holds
webhook-idmsg_ then the job id. The same on every retry, so it can be used to drop duplicates
webhook-timestampWhen it was sent, in seconds since 1 January 1970 (Unix)
webhook-signaturev1, then the base64 signature
content-typeapplication/json
user-agentXPlor3-Webhooks/1.0
Body
{
  "type": "job.succeeded",
  "timestamp": "2026-10-23T08:30:12.000Z",
  "data": {
    "id": "cm...",
    "status": "SUCCEEDED",
    "kind": "IMAGE",
    "modelSlug": "nano-banana-2",
    "creditCost": 9.6,
    "assetIds": ["cm..."],
    "failCode": null,
    "failMessage": null,
    "createdAt": "2026-10-23T08:29:51.000Z",
    "finishedAt": "2026-10-23T08:30:12.000Z"
  }
}
typeWhen
job.succeededThe job finished. Send data.assetIds to POST /v1/assets/urls for download links
job.failedThe job failed. Its credits are already back; the reason is in failCode and failMessage
job.canceledThe job was cancelled while queued. Its credits are already back

The job's full detail, including its input and the size of each file, is still at GET /v1/jobs/{id}.

Checking the signature

The format is Standard Webhooks: HMAC-SHA256 over webhook-id, a dot, webhook-timestamp, a dot, then the raw body, keyed with the secret's base64-decoded bytes after the whsec_ prefix. The standardwebhooks libraries for Node and Python work as they are, or write it yourself as below.

Use the raw body

The signature is computed over the bytes sent. A body that was parsed and turned back into JSON can differ in key order or spacing, and its signature will not match.

xplor3-webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.XPLOR3_WEBHOOK_SECRET;

function verifyXplor3(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"] ?? "";
  if (!id || !timestamp) return false;
  // Refuse anything older than five minutes, so it cannot be replayed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");

  return signatures.split(" ").some((part) => {
    const [version, signature] = part.split(",");
    return (
      version === "v1" &&
      signature !== undefined &&
      signature.length === expected.length &&
      timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    );
  });
}

const app = express();

app.post("/hooks/xplor3", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyXplor3(raw, req.headers, SECRET)) return res.status(401).end();

  const event = JSON.parse(raw);
  res.status(204).end();

  // After answering: store it, fetch the results, tell your user.
  console.log(event.type, event.data.id, event.data.assetIds);
});

app.listen(3000);

Retries

  • Delivered means answered 2xx within 10 seconds. Any other status, a timeout or a failed connection counts as a failure.
  • Eight attempts: 30 seconds apart at first, doubling after each failure, about an hour in all. After that XPlor3 stops trying.
  • Redirects are not followed. A 3xx answer counts as a failure, so use the final address directly.
  • A message can arrive more than once, for example when your server handled it but its answer never arrived. Use webhook-id or data.id to drop duplicates.
  • The result is safe even if every attempt fails. The job stays in the History and can be read with GET /v1/jobs/{id} at any time.

How delivery went shows on GET /v1/jobs/{id}, in the webhook field:

{
  "webhook": {
    "url": "https://example.com/hooks/xplor3",
    "attempts": 2,
    "deliveredAt": "2026-10-23T08:31:05.000Z",
    "lastError": null
  }
}

lastError holds the reason the last failed attempt failed, for example HTTP_503, TIMEOUT, ECONNREFUSED or WEBHOOK_ADDRESS_BLOCKED. For a job without a webhookUrl, webhook is null.

Address requirements

  • HTTPS on port 443, with no user name or password in the URL. A query string is fine, for example a token of your own.
  • Reachable from the internet. localhost, local network addresses such as 10.x, 172.16.x to 172.31.x and 192.168.x, and other internal addresses are refused. The check runs again right before the message is sent, against the IP address the domain name points to.
  • At most 2,048 characters.

To try it from a laptop, use a tunnel that gives you a public HTTPS address, such as ngrok or Cloudflare Tunnel.

Rotating the secret

Press Rotate secret in the Webhook section of the Credentials page. The old secret stops working at once, and every message sent after that is signed with the new one, including retries for jobs that ended earlier. Update the environment variable on your server at the same time.

Without a public server

If your code cannot receive requests from the internet, for example a script on a laptop:

  • GET /v1/jobs/{id}/events sends status changes as Server-Sent Events and closes the connection itself when the job ends.
  • Over MCP, the wait_for_generation tool waits on the server until the job ends, so an agent calls it once. See MCP server.
  • Polling GET /v1/jobs/{id} every 3 to 5 seconds is still fine, and well under the request limit.

On this page