Create a promotion
Start a promotion: choose the vehicles, choose the banners or billboards, and Spyne updates those vehicles' photos. By default it goes live straight away and runs until you complete it.
Save data.id from the response: every later call needs it.
Before you call it
Choose how to pick the vehicles:
| Use | When | Send |
|---|---|---|
Hand-picked (custom) | You know exactly which vehicles, such as a list of aged units | vehicles: up to 500 VINs, stock numbers or registration numbers |
Filter-based (dynamic) | You'd rather describe them, and want vehicles that match later added automatically | criteria: the filter. Optionally excludeVins |
A vehicle can be in only one hand-picked promotion at a time. Run a dry run first: it catches every problem without creating anything.
Image links must be public and reachable; see Add an image. Links are saved as images before the promotion is created, and stay saved even if the create then fails.
Counts toward the 30 changes a minute per rooftop.
Request body
- Name it: send
name. - Choose the vehicles: send either
vehicles(hand-picked) orcriteria(a filter), never both.excludeVinsworks only with a filter. - Choose the images: send 1 to 10 entries in
assets. Each entry has either anassetIdor aurlwith itstype. - Choose when: leave out
scheduleto start now and never end, or setdraftto save it without going live.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Required | 1 to 255 characters. Can't be blank. |
| Vehicles to cover | — | Required | Send vehicles or criteria, not both. |
vehicles | array | Only one | Hand-picked: 1 to 500 vehicles. Each item is a VIN, stock number or registration number as a plain value (string or number), or an object such as { "vin": "…" }, { "stockNumber": "…" } or { "registrationNumber": "…" }. An object may carry several identifiers; if they point to different vehicles the item is ambiguous. The same vehicle listed twice counts once. |
criteria | object | Only one | Filter-based: a vehicle must match every field you send. Fields below. Unknown field names are refused with the list of allowed names. |
criteria.makes, models, trims | string[] | Optional | Matches any of the listed values, such as ["Toyota", "Honda"]. |
criteria.vehicleTypes, condition, transmission | string[] | Optional | Matches any of the listed values. |
criteria.years | integer[] | Optional | Exactly these model years, such as [2023, 2024]. Can't be combined with minYear / maxYear. |
criteria.minYear, maxYear | integer | Optional | A range of model years, from 1981 to two years ahead. Either one alone works. |
criteria.minPrice, maxPrice | number | Optional | Price in dollars, 0 or more. Either one alone works. |
criteria.minMileage, maxMileage | number | Optional | Miles, 0 or more. Either one alone works. |
criteria.minDaysInInventory | number | Optional | Days on the lot. Counted in bands that start at 0, 31, 61 and 91 days, and rounded up to the next band: 45 behaves like 61. |
criteria.certifiedOnly | boolean | Optional | true for certified pre-owned only. |
excludeVins | string[] | Optional | Filter-based only: VINs to leave out even if they match. Sent without criteria, it covers every vehicle in the inventory except these. Not allowed with vehicles. |
assets | array | Required | 1 to 10 images. If two images clash on the same photo, the one listed first wins. The same image can't be listed twice. |
assets[].assetId | string | Only one | An image id from List images or Add an image. |
assets[].url | string | Only one | A public image link. Saved as an image for you, or matched to one you already saved. |
assets[].type | string | Required if | Required with url: banner or billboard. Ignored with assetId, which keeps its saved type. |
assets[].position | string | Optional | Which photos get the image: all_images, first_image, last_image, after_last_image (a new slide at the end), all_exterior, all_interior or custom. Default: all_images for a banner, after_last_image for a billboard. |
assets[].imageIndexes | integer[] | Required if | Required when position is custom: photo numbers starting at 1, such as [1, 4]. Sent without position, it means custom. Not allowed with any other position. |
description | string | Optional | A note for yourself, up to 1000 characters. |
schedule | object | Optional | When the promotion runs. Leave it out to start now and never end. |
schedule.startDate | string | Optional | 2026-10-15 (midnight UTC) or 2026-10-15T09:00:00-04:00; a date and time must include Z or an offset. A future date schedules the promotion. Default: now. |
schedule.endDate | string | Optional | Same format. Must be in the future and after startDate. Leave it out for no end. |
schedule.timezone | string | Optional | Stored with the promotion for your reference, up to 64 characters. It doesn't change how the dates are read. |
draft | boolean | Optional | true saves the promotion without going live. Turn it on later with Change status. Default: false. |
dryRun | boolean | Optional | true only checks the request; see Check a promotion. Default: false. |
priority | integer | Optional | Decides which promotion shows when a vehicle is in more than one. Leave it out, or send 0, to let Spyne choose. Filter-based: 1 to 999. Hand-picked: 1000 to 9999. |
allowReassignment | boolean | Optional | Not supported yet. Leave it out: any value other than false returns 400. To move a vehicle, complete or delete the promotion that holds it first. |
Sample request
curl --request POST \
--url 'https://api.spyne.ai/api/pv1/promotions' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"name": "Fall Clearance",
"vehicles": ["1HGCM82633A004352", "STK-10492"],
"assets": [{ "type": "banner", "url": "https://example.com/fall-offer.png" }]
}'import requests
url = "https://api.spyne.ai/api/pv1/promotions"
headers = {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
}
payload = {
"name": "Fall Clearance",
"vehicles": ["1HGCM82633A004352", "STK-10492"],
"assets": [{"type": "banner", "url": "https://example.com/fall-offer.png"}],
}
response = requests.post(url, headers=headers, json=payload)
print(response.status_code, response.json())const response = await fetch("https://api.spyne.ai/api/pv1/promotions", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Fall Clearance",
vehicles: ["1HGCM82633A004352", "STK-10492"],
assets: [{ type: "banner", url: "https://example.com/fall-offer.png" }],
}),
});
console.log(response.status, await response.json());A filter-based body instead covers every vehicle that matches, using a saved image:
{
"name": "Toyota Offer",
"criteria": { "makes": ["Toyota"], "minPrice": 20000 },
"assets": [{ "assetId": "1584" }]
}Sample response
{
"success": true,
"statusCode": 202,
"message": "Promotion accepted, activation in progress",
"data": {
"id": 351,
"name": "Fall Clearance",
"promotionType": "custom",
"status": "active",
"assets": [{ "assetId": "1584", "position": "all_images" }],
"schedule": { "startDate": "2026-10-01T09:59:00Z", "neverEnds": true },
"priority": 1000,
"vehicleCount": 2
}
}How the promotion starts decides the status code:
| You send | You get |
|---|---|
| Nothing extra (default) | 202, status: "active". The photos update over the next few minutes. |
A future schedule.startDate | 202, status: "scheduled". It goes live by itself on that date. |
"draft": true | 201, status: "draft". Saved, not live. |
| A start now, but Spyne can't activate it | 201 with the reason in message. The promotion exists, so don't create it again: activate it with Change status. |
Response parameters (202, 201)
| Parameter | Type | Description |
|---|---|---|
data.id | integer | The promotion id. Save it. |
data.name | string | As sent. |
data.description | string | Left out when there is none. |
data.promotionType | string | custom (hand-picked) or dynamic (filter-based). |
data.status | string | draft, scheduled, active, paused or completed. |
data.assets[] | array | { assetId, position } for each image, plus imageIndexes when position is custom. |
data.schedule.startDate | string | Start, in UTC. |
data.schedule.endDate | string | End, in UTC. Left out when it never ends. |
data.schedule.neverEnds | boolean | true when there is no end date. |
data.schedule.timezone | string | Left out unless you sent one. |
data.priority | integer | The priority in use; chosen by Spyne when you sent 0 or left it out. |
data.createdAt, data.updatedAt | string | When it was created and last changed. |
data.vehicleCount | integer | Vehicles covered. |
Errors
| Status | Code | When it happens |
|---|---|---|
400 | INVALID_REQUEST | A rule in the body table is broken. details.errors lists every problem at once. Also returned when criteria contains no usable filter, or two images resolve to the same saved image. |
400 | VEHICLE_NOT_FOUND | A vehicle isn't in this rooftop's inventory. data.notFound lists the values you sent. |
400 | VEHICLE_AMBIGUOUS | A value matches two different vehicles. data.ambiguous shows them; send the VIN instead. |
409 | VEHICLE_IN_OTHER_PROMOTION | A vehicle is already in another hand-picked promotion. data.conflicts names it. |
400 | ASSET_NOT_FOUND | An image id can't be used, or a link matches an image that was deleted (change the link, such as adding ?v=2). |
400 | INVALID_ASSET_URL | An image link is malformed, private or unreachable. data.invalid says which and why. |
400 | VALIDATION_FAILED | Spyne refused the request; details explains why. |
400 | TEAM_NOT_RESOLVED | The API key isn't linked to a rooftop. |
401 | UNAUTHORIZED | The API key is missing or wrong. |
429 | RATE_LIMITED | More than 30 changes this minute. Wait the seconds in Retry-After. |
502 / 503 | SERVICE_UNAVAILABLE | A Spyne service is briefly unavailable. Retry shortly. |
Outside a dry run, only the first problem is returned: vehicle problems first, then image problems. Every code is explained in Errors and FAQs.
What happens next
Retrieve the promotion to check its status and performance. To stop it, change its status. There's no edit call yet: to change a promotion, complete or delete it and create a new one.
While this rooftop has a promotion that is active, scheduled or paused, Transform a vehicle refuses a request carrying its own mediaKitConfig.banner or billboard, because the promotion would override it.
Updated 2 days ago
