Skip to content

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.

Company

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.

Asset

The thing with an identity — name, alt text, tags, categories, status. It is not the file.

Version

The file. Replacing a photograph adds a version and repoints the asset; earlier ones stay and can be re-activated.

Collection

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.

Tags & categories

Flat labels and a hierarchy. An asset carries many of both, and ticking a parent category does not tick what sits under it.

Product link

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.

assets.vptag.com/a/{assetId}8.5 MB

The file as uploaded — 4208 × 2448 straight off a camera.

assets.vptag.com/a/{assetId}/web~90 KB

1280px, WebP or AVIF where the browser takes it. Use this on a page.

assets.vptag.com/a/{assetId}/thumb~14 KB

320px, for lists, grids and pickers.

The one trap

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, hSnap up to 160, 320, 640, 960, 1280, 1920, 2560
qSnaps to 70, 82, 92
fitscale-down, contain, cover, crop, pad
fauto, 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/assets

A 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:readAssets, versions, tags and categories
assets:writeUpload, replace files, edit details, publish
metadata:readStructured metadata fields and values
metadata:writeEdit metadata values on assets
collections:readCollections and their contents
collections:writeCreate and edit collections
Note what is absent

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" } }
camelCaseIn requests and responses alike.
Strict fieldsAn unknown field is a 422 naming it, not something quietly dropped.
Rate limit600 requests per minute.
ConcurrencyIf-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.

Publishing

Two independent fields decide whether a public URL resolves. Both must be set.

Status
draftin reviewapprovedpublishedarchived
Visibility
privatepublic

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}/assets

A 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.

Sharing outside the company

A collection, sent to someone with no account at all.

A share link shows that collection and nothing else. Draft and private assets are included — sharing work before it is published is the point of it — but anything in the trash is not. Downloads are re-checked against the collection on every request, so a link cannot be walked outward into the rest of the library. Passwords are stored hashed; revoke at any time and the URL dies with the share.

Roles

Permissions read resource.action. The UI hides buttons on them; the API remains the authority.

Super AdminEverything, across every company. Use sparingly.
Company AdminEverything within one company, including members and integrations.
DAM ManagerAssets, versions, taxonomy, collections, audit. No users or API keys.
EditorCreates and edits assets. No deleting, publishing, users or keys.
ContributorUploads assets and maintains their metadata.
ViewerRead-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.

Verify against the raw bytes

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.

@vpt/payload-dam

The field, the picker component, the server endpoint behind it, and a webhook verifier.

Download the plugin

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
  }),
]
Never expose this key

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.

Assets

Key or session
GET/assetsSearch and filter
GET/assets/{id}
PATCH/assets/{id}Send If-Match: {revision}
DEL/assets/{id}To the trash — recoverable
POST/assets/{id}/restore
DEL/assets/{id}/purge?confirm=permanently-deleteGone for good
POST/assets/bulkTags, categories, status, products across a selection
PUT/assets/{id}/metadata
GET/assets/{id}/signed-urlWorks on anything; expires in five minutes
GET/assets/{id}/versions
POST/assets/{id}/versions/{versionId}/activate
POST/assets/{id}/versions/{versionId}/restore
GET/assets/{id}/versions/{versionId}/download
POST/assets/uploads
POST/assets/uploads/batch
POST/assets/uploads/{uploadId}/complete
POST/assets/{id}/products
DEL/assets/{id}/products/{productId}
GET/products/{productId}/assetsA product's whole gallery, oldest first

Collections & taxonomy

Key or session
GET/collections
POST/collections
GET/collections/{id}
PATCH/collections/{id}
DEL/collections/{id}
POST/collections/{id}/assets
POST/collections/{id}/assets/remove
PUT/collections/{id}/assets/order
GET/tags
POST/tags
POST/tags/merge
DEL/tags/{id}
GET/categories
POST/categories
PATCH/categories/{id}
DEL/categories/{id}
GET/metadata/definitions
POST/metadata/definitions

Company, people, audit

Session
GET/meThe caller and their companies
PATCH/meDisplay name
POST/me/password
GET/me/permissions
GET/organizations
POST/organizationsSuper admin only
GET/organizations/current
PATCH/organizations/current
GET/members
POST/membersAdds them and emails an invitation
POST/members/{userId}/inviteSends it again
PUT/members/{userId}/roles
DEL/members/{userId}
GET/roles
GET/audit

Integrations

Session
GET/integrations/capabilities
GET/api-clients
POST/api-clients
DEL/api-clients/{id}
POST/api-clients/{id}/keysThe secret is shown once
DEL/api-clients/{id}/keys/{keyId}
GET/webhooks
POST/webhooks
PATCH/webhooks/{id}
DEL/webhooks/{id}
POST/webhooks/{id}/rotate-secret
POST/webhooks/{id}/test
GET/webhooks/{id}/deliveries
POST/integrations/picker-sessionsServer-side, from an API key
POST/collections/{id}/shares
GET/collections/{id}/shares
DEL/collections/{id}/shares/{shareId}

Public

No credential
GETassets.vptag.com/a/{assetId}
GETassets.vptag.com/a/{assetId}/web · /thumb
GETassets.vptag.com/key/{companySlug}/{publicKey}
GET/share/{token}A shared collection
POST/share/{token}/assets/{assetId}/download