Car images in WordPress
A must-use plugin: the key in wp-config.php, signed URLs cached as transients, a [car_image] shortcode.
One PHP file in wp-content/mu-plugins adds a [car_image] shortcode; there is nothing to install from the plugin directory. The key stays in wp-config.php. The plugin mints a signed URL with wp_remote_post, keeps it in a transient until a day before it expires, and prints a plain <img>. Visitors' browsers load the image from carimage.dev and never see the key.
1. Put the key in wp-config.php
// Above the line /* That's all, stop editing! */
define( 'CAR_IMAGE_API_KEY', 'cimg_your_key_here' );If your host sets environment variables, read it from there instead: define( 'CAR_IMAGE_API_KEY', getenv( 'CAR_IMAGE_API_KEY' ) );. Keep it out of theme files, posts and plugin settings, and never in a URL. A key created in the dashboard can mint signed URLs.
2. Add the must-use plugin
Save this as wp-content/mu-plugins/car-image.php. Must-use plugins load on every request and cannot be switched off from the admin; WordPress reads only the PHP files directly in that folder, not in subfolders.
<?php
/**
* Plugin Name: Car Image API
* Description: [car_image vehicle="veh_…" view="side"] shows a Car Image API render through a signed URL.
*/
defined( 'ABSPATH' ) || exit;
/**
* A signed URL for one image, minted once and reused until a day before it expires.
* Returns null when no URL is available; the shortcode then prints nothing.
*/
function car_image_signed_url( array $image ) {
if ( ! defined( 'CAR_IMAGE_API_KEY' ) || ! CAR_IMAGE_API_KEY ) {
return null;
}
$body = wp_json_encode( array( 'images' => array( $image ), 'ttl_seconds' => WEEK_IN_SECONDS ) );
$transient = 'car_image_' . md5( $body );
$cached = get_transient( $transient );
if ( false !== $cached ) {
return '' === $cached ? null : $cached;
}
$response = wp_remote_post(
'https://carimage.dev/api/v1/image-urls',
array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Bearer ' . CAR_IMAGE_API_KEY,
'Content-Type' => 'application/json',
// Same body in the same hour: a concurrent render gets this mint, not a second charge.
'Idempotency-Key' => 'wp-' . md5( $body . gmdate( 'Y-m-d-H' ) ),
),
'body' => $body,
)
);
if ( is_wp_error( $response ) ) {
error_log( 'Car Image API: ' . $response->get_error_message() );
set_transient( $transient, '', 5 * MINUTE_IN_SECONDS );
return null;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 201 === $status && ! empty( $data['data'][0]['url'] ) ) {
// 1 credit, charged now. Keep the URL for six of its seven days.
set_transient( $transient, $data['data'][0]['url'], WEEK_IN_SECONDS - DAY_IN_SECONDS );
return $data['data'][0]['url'];
}
if ( 409 === $status ) {
return null; // the same mint is still running in another request; it fills the cache
}
// 402: no credits left, or the plan's monthly vehicle cap. Retrying cannot fix it.
error_log( sprintf( 'Car Image API answered %d: %s', $status, $data['detail'] ?? '' ) );
set_transient( $transient, '', 5 * MINUTE_IN_SECONDS ); // ask again in five minutes
return null;
}
add_shortcode(
'car_image',
function ( $atts ) {
$atts = shortcode_atts(
array(
'vehicle' => '',
'view' => 'front-3-4',
'color' => '',
'width' => 600,
'height' => 400,
'alt' => '',
),
$atts,
'car_image'
);
// A stable vehicle id, such as veh_78qtwrgh37bkr.
if ( ! preg_match( '/^veh_[0-9a-hjkmnp-tv-z]{13}$/', $atts['vehicle'] ) ) {
return '';
}
$width = max( 1, min( 1024, (int) $atts['width'] ) );
$height = max( 1, min( 1024, (int) $atts['height'] ) );
$image = array(
'vehicle' => $atts['vehicle'],
'view' => sanitize_key( $atts['view'] ),
'width' => $width,
'height' => $height,
'trim' => true,
'format' => 'auto',
);
if ( '' !== $atts['color'] ) {
$image['color'] = sanitize_text_field( $atts['color'] );
}
$url = car_image_signed_url( $image );
if ( null === $url ) {
return '';
}
return sprintf(
'<img src="%s" alt="%s" width="%d" height="%d" loading="lazy" decoding="async">',
esc_url( $url ),
esc_attr( $atts['alt'] ),
$width,
$height
);
}
);
- One URL per distinct image. Each combination of vehicle, view, paint and size costs 1 credit when it is minted, then nothing for 6 of the URL's 7 days, however many pages show it. Nothing is minted for pages nobody renders.
- The Idempotency-Key changes every hour. Within the hour, a second mint of the same image is not billed again: while the first is still running it gets a
409and prints nothing that once, and afterwards it gets the first answer back. That replay includes a refusal, which is why the key does not last longer. - Failures wait five minutes. A busy page does not call the API on every view while an image is unavailable; the reason goes to the PHP error log.
3. Use the shortcode
[car_image vehicle="veh_78qtwrgh37bkr" view="side" alt="2024 Porsche 911, side view"]
[car_image vehicle="veh_78qtwrgh37bkr" view="front-3-4" color="#1a2b3c" width="800" height="500" alt="2024 Porsche 911 in dark blue"]Put it in a Shortcode block, a classic post or a widget; in a theme template, call do_shortcode(). view is one of the camera angles on Views & sizes, color a preset name or any hex paint, and width and height the box the car is trimmed into, up to 1024. Write an alt for every image that carries meaning.
<?php echo do_shortcode( '[car_image vehicle="veh_78qtwrgh37bkr" view="side" alt="2024 Porsche 911, side view"]' ); ?>Find each vehicle's id once with the free lookups, from the CLI or through resolve and VIN decoding. Ids never change, so the shortcode keeps working for as long as the page exists.
# Free: words to a vehicle id (ask a person when the confidence is low)
npx @meterapp/car-image resolve "2024 Porsche 911"
# Free: a VIN to a vehicle id
npx @meterapp/car-image vin 1HGCM82633A0043524. Page caches
A caching plugin, your host's page cache or a CDN can serve a page long after it was rendered, and a signed URL answers 403 once it expires. The plugin hands out only URLs with at least a day left, so keep page caches shorter than a day. If they have to be longer, mint renewing URLs: a renewing URL keeps loading after it expires, and the first load in each further week bills one more credit, for 365 days. Change the body in the plugin, and keep the transient as long as you like within that period.
$body = wp_json_encode(
array(
'images' => array( $image ),
'ttl_seconds' => WEEK_IN_SECONDS,
'renew' => true, // keeps loading after it expires: 1 credit per further week it is opened
)
);Transients can disappear before they expire, for example when an object cache evicts them. The plugin then mints again, for one credit.
5. When the account cannot pay
A 402 means the balance is empty or the plan's monthly cap on distinct vehicles is reached, and nothing was charged. The plugin logs it and prints nothing for that image, asking again every five minutes. For an empty balance, add credits or turn on auto-reload in the dashboard; the vehicle cap resets with the calendar month, and a higher plan raises it. Because the key replays its first answer for the rest of the hour, an image can stay missing for up to an hour after the fix. See the error reference.
403 there. See Restrict URLs to your sites.