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.

POSThttps://api.spyne.ai/api/pv1/promotions
AuthBearer tokenRate limit30 changes / min per rooftop

Before you call it

Choose how to pick the vehicles:

UseWhenSend
Hand-picked (custom)You know exactly which vehicles, such as a list of aged unitsvehicles: up to 500 VINs, stock numbers or registration numbers
Filter-based (dynamic)You'd rather describe them, and want vehicles that match later added automaticallycriteria: 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

Requiredalways sendOnly onesend exactly one from the groupOptionalcan be left outRequired ifrequired only in the case described
Request checklist
  1. Name it: send name.
  2. Choose the vehicles: send either vehicles (hand-picked) or criteria (a filter), never both. excludeVins works only with a filter.
  3. Choose the images: send 1 to 10 entries in assets. Each entry has either an assetId or a url with its type.
  4. Choose when: leave out schedule to start now and never end, or set draft to save it without going live.
ParameterTypeRequiredDescription
namestringRequired1 to 255 characters. Can't be blank.
Vehicles to cover—RequiredSend vehicles or criteria, not both.
vehiclesarrayOnly oneHand-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.
criteriaobjectOnly oneFilter-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, trimsstring[]OptionalMatches any of the listed values, such as ["Toyota", "Honda"].
criteria.vehicleTypes, condition, transmissionstring[]OptionalMatches any of the listed values.
criteria.yearsinteger[]OptionalExactly these model years, such as [2023, 2024]. Can't be combined with minYear / maxYear.
criteria.minYear, maxYearintegerOptionalA range of model years, from 1981 to two years ahead. Either one alone works.
criteria.minPrice, maxPricenumberOptionalPrice in dollars, 0 or more. Either one alone works.
criteria.minMileage, maxMileagenumberOptionalMiles, 0 or more. Either one alone works.
criteria.minDaysInInventorynumberOptionalDays 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.certifiedOnlybooleanOptionaltrue for certified pre-owned only.
excludeVinsstring[]OptionalFilter-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.
assetsarrayRequired1 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[].assetIdstringOnly oneAn image id from List images or Add an image.
assets[].urlstringOnly oneA public image link. Saved as an image for you, or matched to one you already saved.
assets[].typestringRequired ifRequired with url: banner or billboard. Ignored with assetId, which keeps its saved type.
assets[].positionstringOptionalWhich 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[].imageIndexesinteger[]Required ifRequired 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.
descriptionstringOptionalA note for yourself, up to 1000 characters.
scheduleobjectOptionalWhen the promotion runs. Leave it out to start now and never end.
schedule.startDatestringOptional2026-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.endDatestringOptionalSame format. Must be in the future and after startDate. Leave it out for no end.
schedule.timezonestringOptionalStored with the promotion for your reference, up to 64 characters. It doesn't change how the dates are read.
draftbooleanOptionaltrue saves the promotion without going live. Turn it on later with Change status. Default: false.
dryRunbooleanOptionaltrue only checks the request; see Check a promotion. Default: false.
priorityintegerOptionalDecides 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.
allowReassignmentbooleanOptionalNot 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 sendYou get
Nothing extra (default)202, status: "active". The photos update over the next few minutes.
A future schedule.startDate202, status: "scheduled". It goes live by itself on that date.
"draft": true201, status: "draft". Saved, not live.
A start now, but Spyne can't activate it201 with the reason in message. The promotion exists, so don't create it again: activate it with Change status.

Response parameters (202, 201)

ParameterTypeDescription
data.idintegerThe promotion id. Save it.
data.namestringAs sent.
data.descriptionstringLeft out when there is none.
data.promotionTypestringcustom (hand-picked) or dynamic (filter-based).
data.statusstringdraft, scheduled, active, paused or completed.
data.assets[]array{ assetId, position } for each image, plus imageIndexes when position is custom.
data.schedule.startDatestringStart, in UTC.
data.schedule.endDatestringEnd, in UTC. Left out when it never ends.
data.schedule.neverEndsbooleantrue when there is no end date.
data.schedule.timezonestringLeft out unless you sent one.
data.priorityintegerThe priority in use; chosen by Spyne when you sent 0 or left it out.
data.createdAt, data.updatedAtstringWhen it was created and last changed.
data.vehicleCountintegerVehicles covered.

Errors

StatusCodeWhen it happens
400INVALID_REQUESTA 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.
400VEHICLE_NOT_FOUNDA vehicle isn't in this rooftop's inventory. data.notFound lists the values you sent.
400VEHICLE_AMBIGUOUSA value matches two different vehicles. data.ambiguous shows them; send the VIN instead.
409VEHICLE_IN_OTHER_PROMOTIONA vehicle is already in another hand-picked promotion. data.conflicts names it.
400ASSET_NOT_FOUNDAn image id can't be used, or a link matches an image that was deleted (change the link, such as adding ?v=2).
400INVALID_ASSET_URLAn image link is malformed, private or unreachable. data.invalid says which and why.
400VALIDATION_FAILEDSpyne refused the request; details explains why.
400TEAM_NOT_RESOLVEDThe API key isn't linked to a rooftop.
401UNAUTHORIZEDThe API key is missing or wrong.
429RATE_LIMITEDMore than 30 changes this minute. Wait the seconds in Retry-After.
502 / 503SERVICE_UNAVAILABLEA 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.


Did this page help you?