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:readpermission (for polling) andplugin.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
2xxstatus — 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 seejobs_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
| Error | Why it happened | What to do |
|---|---|---|
| Webhooks not arriving | No callback_url on the installation, or the endpoint doesn't respond 2xx | Set callback_url in the installation settings; check your endpoint's logs |
| Signature verification fails | You're verifying the processed body instead of the raw one; wrong secret; the rotation window has passed | Sign exactly the raw body bytes; after a rotation, update the secret within 48 h |
409 on replay | The original delivery isn't in a state that can be re-sent | Only re-send failed/abandoned deliveries |
409 job_not_completed on refresh-url | The job is still running | Wait for status: "completed" — details |
410 on refresh-url | The files were deleted per the retention policy | Copy files to your own storage right after generation — details |
Next steps
- Webhook protocol — the full signature and reliability contract.
- Bulk sessions — webhooks with a large number of jobs.
- Repeat session — another round of images.