Webhooks
Account endpoints that hear when a render, 3D model or upgrade is ready; signed deliveries, retries and 30 days of events to poll.
Register an HTTPS endpoint once and Car Image tells it when something about the images your account uses is ready: a new render stored, a 3D model built, a paid quality upgrade delivered. Each delivery is signed, retried when your server is down, and readable again for 30 days. Endpoints are free; every event but the test needs a paid plan in force.
Events
| Type | When |
|---|---|
webhook.test | Sent by POST /api/v1/webhooks/{id}/test to that endpoint only. |
image.ready | A new render the account requested was made and stored, and can be served. |
3d_model.ready | A 3D model the account ordered is ready to download. |
3d_model.failed | A 3D model the account ordered could not be made; its credits were refunded. |
image_upgrade.succeeded | A paid quality upgrade was delivered in place. |
image_upgrade.failed | A paid quality upgrade could not be delivered; its credits were refunded. |
image.upgraded | A view the account was served was raised to higher quality in place. |
image.issue_reported | Someone reported a problem with a view the account was served. |
image.issue_resolved | A reported problem with a view the account was served was fixed or closed. |
image.replaced | A view the account was served has new bytes for another reason (re-derived, new paint key). |
vehicle.available | A vehicle the account asked or voted for was added to the catalog. |
pack.batch.completed | Every item of an Image Pack batch is ready or failed; failed items gave their image back to the pack. |
pack.export.ready | A ZIP export of an Image Pack is built; its links are valid for 7 days. |
pack.window.ending | An Image Pack's 30 days of updates end in 3 days. |
pack.window.ended | An Image Pack's 30 days of updates ended; its images stay yours to self-host, and its archive stays downloadable. |
3d_model.* carries the same JSON as GET /api/v1/3d/{id} and image_upgrade.* the same as GET /api/v1/images/upgrades/{id}. They arrive on account endpoints in addition to a request's own webhook_url, which keeps working as before.
Updates to images you were served
Every image your account is billed for while a paid plan is in force is watched: when that view is later raised to higher quality (image.upgraded), reported by anyone in the network (image.issue_reported), fixed or closed (image.issue_resolved), or given new bytes for another reason such as a re-render or a new paint key (image.replaced), your endpoints hear about it once. Every color of a view shares one stored image, so the watch is per vehicle and view. If you keep your own copies, fetch the image again from data.url (billed as any image request).
A vehicle's first image is served fast at standard quality and its view redrawn at enhanced quality in the background, usually within a day (first views). When that redraw replaces it, image.upgraded arrives with reason: "first_view". Other reasons are popularity, customer (someone paid for the upgrade) and operator.
Issue events say what kind of problem it is (category: identity, camera, paint or quality), never who reported it or what they wrote.
{
"id": "evt_3f1a9c0d2e4b6a8c0e2f4a6b8c0d2e4f",
"type": "image.upgraded",
"created_at": "2026-10-23T09:41:05.117Z",
"data": {
"object": "image_update",
"vehicle": {
"id": "veh_errc87t1jgata",
"make": "toyota",
"model": "camry",
"year": 2025
},
"view": "rear-3-4",
"color": "#1a2b3c",
"quality": "enhanced",
"revision": "4c7d…",
"url": "https://carimage.dev/api/v1/images/car?vehicle=veh_errc87t1jgata&view=rear-3-4&color=%231a2b3c",
"status": "upgraded",
"reason": "popularity"
}
}vehicle.available tells the accounts of everyone who asked or voted for a vehicle on the request board that it is in the catalog, with the URL that renders it.
webhook.test, nothing else, and images served on Free are not watched. When a plan ends, events stop without touching your endpoints, and start again when it renews.Add an endpoint
/api/v1/webhooks| Parameter | Type | Description |
|---|---|---|
url | string | HTTPS on a public host; no credentials or fragment. One endpoint per URL. |
events | string[] | Event types, or ["*"] (the default) for every type, including types added later. |
description | string | Optional, up to 200 characters. |
curl --fail-with-body \
-X POST https://carimage.dev/api/v1/webhooks \
-H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/carimage","events":["image.ready","3d_model.ready"],"description":"catalog sync"}'{
"data": {
"id": "whe_3f9a1c2b7d5e4f60a1b2c3d4e5f60718",
"object": "webhook_endpoint",
"url": "https://example.com/hooks/carimage",
"events": [
"image.ready",
"3d_model.ready"
],
"description": "catalog sync",
"status": "active",
"disabled_reason": null,
"secret_prefix": "whsec_q7Lm",
"consecutive_failures": 0,
"failing_since": null,
"last_success_at": null,
"created_at": "2026-10-22T18:00:00.000Z",
"updated_at": "2026-10-22T18:00:00.000Z",
"secret": "whsec_q7Lm…"
},
"entitlement": {
"plan": "pro",
"receives": "all"
},
"request_id": "req_01j9x…"
}data.secret is shown once: store it on your server as CAR_IMAGE_WEBHOOK_SECRET. An account holds at most 10 endpoints. Changing endpoints takes an API key; a connected app's token can list them but not change them. You can do all of this on the dashboard's Webhooks page too.
/api/v1/webhooks/api/v1/webhooks/{id}/api/v1/webhooks/{id}/api/v1/webhooks/{id}PATCH takes url, events, description and status: "disabled" stops deliveries, "active" turns the endpoint back on with a fresh failure count. DELETE removes it with its delivery history.
What a delivery looks like
One POST per event and endpoint, with a JSON body and three headers:
X-CarImage-Event: the event type.X-CarImage-Delivery: the delivery id (whd_…), the same on every retry.X-CarImage-Signature:t=<unix seconds>,sha256=<hex>, an HMAC-SHA256 of<t>.<raw body>keyed with the endpoint's whole secret.
{
"id": "evt_8c0e2f4a6b1d3c5e7f9a0b2c4d6e8f01",
"type": "image.ready",
"created_at": "2026-10-22T18:03:12.481Z",
"data": {
"object": "image",
"vehicle": {
"id": "veh_errc87t1jgata",
"make": "toyota",
"model": "camry",
"year": 2025
},
"view": "rear-3-4",
"color": "#1a2b3c",
"quality": "standard",
"sha256": "9b2e…",
"url": "https://carimage.dev/api/v1/images/car?vehicle=veh_errc87t1jgata&view=rear-3-4&color=%231a2b3c"
}
}Answer with any 2xx within 10 seconds. A timeout, a redirect or any other status is a failed attempt, tried again 1 min, 5 min, 30 min, 2 h, 12 h, 24 h later, then given up (resend it from the dashboard). An endpoint that fails for three days straight is turned off and the account owner gets an email; the events stay readable below. Deliveries can repeat and can arrive out of order, so dedupe on id and read created_at.
Verify the signature
Check the signature over the raw bytes before you parse them, and refuse a timestamp more than five minutes old. The SDK's verifyWebhook() does both and returns the event:
import { verifyWebhook } from "@meterapp/car-image-sdk";
// Read the raw body: the signature covers the exact bytes we sent.
export async function POST(request: Request) {
const body = await request.text();
let event;
try {
event = await verifyWebhook(body, request.headers.get("x-carimage-signature"), process.env.CAR_IMAGE_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 401 });
}
if (await alreadyHandled(event.id)) return new Response(null, { status: 204 }); // deliveries can repeat
if (event.type === "image.ready") {
// event.data: vehicle, view, color, quality, sha256 and url (fetch it again to store a copy)
}
return new Response(null, { status: 204 }); // any 2xx within 10 s stops the retries
}In another language: split the header on commas, recompute HMAC-SHA256(secret, t + "." + body) as hex and compare it in constant time.
Send a test
/api/v1/webhooks/{id}/test/api/v1/webhooks/{id}/deliveriesThe test queues a signed webhook.test event for that endpoint alone; it arrives within a minute. The deliveries list shows each attempt with your server's status code.
Poll instead
/api/v1/eventscurl -s -H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
"https://carimage.dev/api/v1/events?type=image.ready&since=2026-10-01T00:00:00Z&limit=100"
# -> data: [events, oldest first], has_more; page on with starting_after=<last id>The same events, oldest first, for the last 30 days, whether or not an endpoint received them: type, since, starting_after and limit (1–100). A server that cannot take inbound requests can poll this every few minutes.