Receiving results

HMAC-signed webhooks, retries and re-sending, polling as an alternative, and refreshing expired download URLs.

What you'll achieve

Generation is asynchronous — you submit a session, and the results trickle in after tens of seconds or minutes. In this tutorial you'll set up webhook delivery (with signature verification), learn about polling as a simpler alternative, and learn how to refresh expired download URLs.

Prerequisites

  • A plugin installation API key (how to get one) with the plugin.jobs:read permission (for polling) and plugin.webhooks:manage (for re-sending webhooks).
  • For webhooks: a publicly reachable HTTPS address for your plugin, set as the installation's callback_url, plus the installation's HMAC secret.

Flow

webhook (push)                  GET /jobs/{id} (pull)
  ├─ verify the HMAC signature    ├─ check the status
  ├─ respond 2xx                  └─ download outputs[].url
  └─ download outputs[].url
URL expired? → POST /jobs/{id}/refresh-url
missed webhook? → POST /webhooks/{delivery_id}/replay

Steps — webhooks

1. Receive the notification

When a job reaches a final state, we send a POST to your installation's callback_url. The event field takes the values job.completed, job.failed, or job.cancelled.

{
  "event": "job.completed",
  "delivered_at": "2026-06-03T08:00:00.000Z",
  "job": {
    "id": "00000000-0000-0000-0000-0000000000a1",
    "status": "completed",
    "order_id": "00000000-0000-0000-0000-000000000099",
    "completed_at": "2026-06-03T07:59:40.000Z",
    "error": null
  },
  "outputs": [
    {
      "url": "https://…?token=…",
      "type": "image/jpeg",
      "width": 1024,
      "height": 1280
    }
  ],
  "external_metadata": { "sku": "SKU-001" }
}

The external_metadata field comes back exactly as you sent it when creating the session — use it to match the result to your own order (e.g. put your shop's product ID there).

2. Verify the signature

Every notification has a header:

X-Qamera-Signature: t=<unix-time>,v1=<hmac_sha256_hex>

The signed string is <t>.<raw-body>, signed with your installation's HMAC secret. Reject notifications with a t older than 5 minutes. Example in Node.js:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headerValue, secret) {
  const parts = Object.fromEntries(
    headerValue.split(',').map((p) => p.split('=')),
  );
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;

  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'));
}

and in PHP:

function qamera_verify($rawBody, $header, $secret) {
  parse_str(strtr($header, ',', '&'), $parts);
  if (!isset($parts['t']) || !isset($parts['v1'])) return false;
  if (abs(time() - (int)$parts['t']) > 300) return false;
  $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
  return hash_equals($expected, $parts['v1']);
}

After you rotate the secret (POST /installations/{id}/rotate-hmac), notifications carry two v1= segments for 48 hours — one for the old secret, one for the new one. Accept either. The full signature contract is described in the webhook protocol.

3. Respond quickly and process idempotently

  • Respond with any 2xx status — ideally right away, and do the processing in the background.
  • Process idempotently (e.g. by job.id + event): on a slow response we may re-send a notification that was already delivered.
  • Store (event, job.id, delivered_at) — it helps with troubleshooting.

4. Retries and re-sending

If your endpoint doesn't respond with 2xx, we retry up to 8 times with a growing interval (up to 1 hour). After 5 consecutive failed deliveries we pause sending for 30 minutes; after 3 such cycles the installation is suspended.

You can re-send a notification that failed to deliver:

curl -X POST https://qamera.ai/api/v1/plugin/webhooks/00000000-0000-0000-0000-0000000000d1/replay \
  -H "X-Api-Key: mk_live_xxxxxxxx.yyyyyyyy"

Returns 202 with the identifier of the new delivery.

Steps — polling (alternative)

Don't want to maintain a public endpoint? Poll for job state:

# a single job
curl https://qamera.ai/api/v1/plugin/jobs/00000000-0000-0000-0000-0000000000a1 \
  -H "X-Api-Key: mk_live_xxxxxxxx.yyyyyyyy"

# all completed since a given moment
curl "https://qamera.ai/api/v1/plugin/jobs?status=completed&created_after=2026-06-03T00:00:00Z&limit=50" \
  -H "X-Api-Key: mk_live_xxxxxxxx.yyyyyyyy"

Tips:

  • Poll every 15–30 seconds, no more often — the key's request limit (60/min by default) has to accommodate your other calls too.
  • The state of a whole session at once is returned by GET /orders/{id} per product you'll see jobs_total, jobs_completed, jobs_failed, and the list of results.
  • You can combine webhooks and polling: webhooks as the main channel, polling as a safety net.

Expired download URLs

The URLs in outputs[].url are valid for at least 7 days. After that, fetch fresh ones:

curl -X POST https://qamera.ai/api/v1/plugin/jobs/00000000-0000-0000-0000-0000000000a1/refresh-url \
  -H "X-Api-Key: mk_live_xxxxxxxx.yyyyyyyy"

The response contains new outputs[] and expires_at. Better still, copy the files to your own storage right after you receive the result — don't treat our URLs as permanent hosting.

Common errors

ErrorWhy it happenedWhat to do
Webhooks not arrivingNo callback_url on the installation, or the endpoint doesn't respond 2xxSet callback_url in the installation settings; check your endpoint's logs
Signature verification failsYou're verifying the processed body instead of the raw one; wrong secret; the rotation window has passedSign exactly the raw body bytes; after a rotation, update the secret within 48 h
409 on replayThe original delivery isn't in a state that can be re-sentOnly re-send failed/abandoned deliveries
409 job_not_completed on refresh-urlThe job is still runningWait for status: "completed"details
410 on refresh-urlThe files were deleted per the retention policyCopy files to your own storage right after generation — details

Next steps