Documentation
How the DAM is put together, the URLs it serves, and the API other systems build on.
The model
Six things, and one distinction everything else follows from.
The tenant. Assets, tags, categories, collections and API keys each belong to exactly one, and nothing crosses between them. A person can be a member of several, with different roles in each.
The thing with an identity — name, alt text, tags, categories, status. It is not the file.
The file. Replacing a photograph adds a version and repoints the asset; earlier ones stay and can be re-activated.
A hand-ordered set. First-class in the API, filterable on any search with collectionId, the unit a share link is built from, and something an API key can be gated to.
Flat labels and a hierarchy. An asset carries many of both, and ticking a parent category does not tick what sits under it.
An identifier from another system — a SKU. Many assets can hang off one product; the lead image is the one marked primary.
An asset’s id is permanent and its file is not. Every URL below is built from the id, so nothing you store breaks when someone uploads a better photograph.
URLs
Public, no credential, no expiry — and no query string to compose.
The file as uploaded — 4208 × 2448 straight off a camera.
1280px, WebP or AVIF where the browser takes it. Use this on a page.
320px, for lists, grids and pickers.
These serve an asset only while it is both published and public. Either one short and the URL answers 404 — which on a live site reads as a broken image rather than a permissions decision.
A size the presets don’t cover
assets.vptag.com/a/{assetId}?w=640&fit=scale-down&f=auto&q=82| w, h | Snap up to 160, 320, 640, 960, 1280, 1920, 2560 |
| q | Snaps to 70, 82, 92 |
| fit | scale-down, contain, cover, crop, pad |
| f | auto, webp, avif, jpeg, png |
Values snap rather than pass through. Every distinct combination is a separately cached and separately billed variant, so one loop over ?w=1..5000 would otherwise mint five thousand of them for a single image. Snapping upward also means a request for 700px gets 960px scaled down by the browser, never a blurry 640px enlarged.
Aliases, and anything not published
An asset can carry a readable public key and answer on assets.vptag.com/key/{companySlug}/{publicKey} as well — useful for a logo a hundred documents link to. For anything unpublished, GET /assets/{id}/signed-url returns a URL that expires in five minutes. Fetch one when you render; storing it is the mistake that endpoint invites.
The API
Base https://api.vptag.com/api/v1 — two ways in, for two kinds of caller.
A key, for another system
curl -H "Authorization: Bearer dam_live_ab12_…" \
https://api.vptag.com/api/v1/assetsA key belongs to one company, so it needs no company header. It can be narrowed when issued — to given categories and collections — and then sees only those assets on every endpoint. dam_test_… keys behave identically.
What a key may do
| assets:read | Assets, versions, tags and categories |
| assets:write | Upload, replace files, edit details, publish |
| metadata:read | Structured metadata fields and values |
| metadata:write | Edit metadata values on assets |
| collections:read | Collections and their contents |
| collections:write | Create and edit collections |
No scope grants delete, restore or purge — and none grants user management, API keys or webhooks. Destroying an asset, or handing out access, is not something a long-lived credential in someone else’s deployment should be able to do.
A session, for this admin
A cookie, plus X-Organization-Id naming the company — since a person can belong to several.
What comes back
{ "data": { … } }
{ "data": [ … ],
"pagination": { "page": 1, "pageSize": 50, "total": 212 } }
{ "error": {
"code": "REVISION_MISMATCH",
"message": "This asset was modified by someone else (current revision 5).",
"request_id": "req_ik30t85zgij" } }| camelCase | In requests and responses alike. |
| Strict fields | An unknown field is a 422 naming it, not something quietly dropped. |
| Rate limit | 600 requests per minute. |
| Concurrency | If-Match: {revision} makes a competing edit fail instead of overwriting. |
Uploading
Three steps. The binary goes straight to storage and never through the API.
# 1. Ask for somewhere to put it.
POST /assets/uploads
{ "filename": "front-left.png", "mimeType": "image/png", "size": 8906359 }
# → uploadId, uploadUrl, method: "PUT", headers, expiresAt
# 2. PUT the bytes at uploadUrl with exactly those headers.
# They are part of the signature; changing one invalidates it.
# 3. Tell the DAM it landed. This creates the asset.
POST /assets/uploads/{uploadId}/complete
{ "displayName": "DT612ST front left", "tags": ["Exterior"] }Thumbnails, dimensions and the checksum follow a moment later — currentVersion.processingStatus reads ready when the worker has finished. Ceilings: images 100 MB, documents 250 MB, video 2 GB. To replace a file rather than add an asset, pass assetId in step 1 — the asset’s URL does not change.
Finding things
Full text over name, description, filename, tags and metadata — ranked, and forgiving of a typo.
GET /assets?q=trailer&assetType=image&status=published&pageSize=50
| q | Words and prefixes. Two characters is the minimum the UI searches on. |
| status | draft, in_review, approved, published, archived |
| assetType | image, video, document, audio, archive, other |
| tag, categoryId, collectionId, productId | Filters, combinable |
| createdBy, createdFrom/To, updatedFrom/To | Who and when |
| deleted | exclude (default), include, only |
| sort | -updatedAt, displayName… omit alongside q for relevance order |
Publishing
Two independent fields decide whether a public URL resolves. Both must be set.
Changing status needs assets.publish; everything else about an asset needs assets.update. The asset screen says why a URL will not resolve in place of showing it, and the picker returns deliveryUrlResolves so an integration can refuse to store a link that would 404 weeks later.
Products
Linking assets to identifiers from another system.
POST /assets/{assetId}/products
{ "products": [{ "productId": "SKU-100", "role": "primary" }] }
DELETE /assets/{assetId}/products/{productId}
GET /products/{productId}/assetsA product may have as many images as you like. The only thing that cannot repeat is the same image on the same product twice. Promoting one asset to primary demotes the previous holder in the same transaction, so a product never briefly has two lead images. The gallery comes back oldest-first — the order they were attached.
Roles
Permissions read resource.action. The UI hides buttons on them; the API remains the authority.
| Super Admin | Everything, across every company. Use sparingly. |
| Company Admin | Everything within one company, including members and integrations. |
| DAM Manager | Assets, versions, taxonomy, collections, audit. No users or API keys. |
| Editor | Creates and edits assets. No deleting, publishing, users or keys. |
| Contributor | Uploads assets and maintains their metadata. |
| Viewer | Read-only. |
Webhooks
Signed, retried, and inspectable after the fact.
Every delivery carries X-DAM-Signature (sha256=…) and X-DAM-Timestamp. The signature is HMAC-SHA256 over {timestamp}.{body} using the webhook secret. Deliveries retry with backoff up to eight times, and every attempt is visible on the webhook screen.
Parsing the JSON and re-serialising it changes them — different key order, different whitespace — and the signature will never match. This is the single most common way the integration fails. Reject anything older than five minutes; a signature on its own never expires.
Payload CMS
A plugin, so a website editor picks an image from this library without leaving Payload.
The field, the picker component, the server endpoint behind it, and a webhook verifier.
1. Install it
Unzip it into the Payload project — vendor/vpt-payload-dam is a reasonable home — and install from that folder.
pnpm add ./vendor/vpt-payload-dam
2. Register it
// payload.config.ts
import { damPlugin } from '@vpt/payload-dam'
plugins: [
damPlugin({
apiUrl: 'https://api.vptag.com',
siteOrigin: process.env.SITE_ORIGIN!,
apiKey: process.env.DAM_API_KEY!, // server-side only
}),
]DAM_API_KEY must not carry a NEXT_PUBLIC_ prefix. Everything in the admin panel reaches the browser, and that key opens the whole library — the plugin refuses to start if it sees one.
3. Use the field
import { damAsset } from '@vpt/payload-dam/fields'
fields: [damAsset(), damAsset({ name: 'ogImage', label: 'Social image' })]Then run payload generate:importmap. Payload resolves custom components through that map; without regenerating it the field renders as a plain group with no picker button.
What it stores
{
assetId: '01J…',
url: 'https://assets.vptag.com/a/01J…/web', // render this
originalUrl: 'https://assets.vptag.com/a/01J…', // the file as uploaded
alt, width, height, mimeType,
}Both URLs are permanent and safe to keep in your database. The field holds one image; if a document needs a gallery, add a second damAsset() field or an array field of your own.
The zip carries a README with the webhook route, the publishing requirement and how to migrate existing Payload uploads.
Every endpoint
Paths are relative to api.vptag.com/api/v1 unless marked otherwise.