Assets
The assets API is your image asset library — the same images your dashboard shows under Asset Library. An asset is a set of image bytes hosted on your Edge CDN, reachable from a template by a logical path: the string you write as {{asset "static/logo.png"}}, or as a relative src attribute.
The surface is a full CRUD: upload with POST /v1/assets, read with GET, remove with DELETE /v1/assets/{id}. To bring in an image hosted elsewhere, POST /v1/assets/import fetches it by URL, converts an SVG to PNG, and stores it at a content-addressed path, so importing the same image twice is a no-op. It returns the {{asset}} reference to use, and never edits a template. Images also travel inside a bundle — GET /v1/export includes them and POST /v1/import applies them — which is the usual route when a pipeline is pushing templates and their images together.
The CDN URL needs an Edge stack
An asset’s url is its public CloudFront address. It is only populated once your Edge stack is active — the stack is where the S3 bucket and CloudFront distribution that serve images live. An org with no active Edge CDN has nowhere to put the bytes at all, so an upload is refused with 409 edge_cdn_not_provisioned rather than stored without a home.
How it maps to the dashboard
| Dashboard | API |
|---|---|
| Asset Library | GET /v1/assets |
| A single image’s detail page | GET /v1/assets/{id} |
| Upload an image | POST /v1/assets |
| Remove an image | DELETE /v1/assets/{id} |
Scopes
| Scope | Grants | PII |
|---|---|---|
api.assets.view | GET /v1/assets, GET /v1/assets/{id} | No — image metadata only (path, CDN URL, hash, dimensions). No recipient data. |
api.assets.manage | POST /v1/assets, DELETE /v1/assets/{id} | No — operates on image bytes and their paths. |
Images inside a bundle are also covered by the templates scopes: api.templates.view includes them in GET /v1/export, and api.templates.manage uploads them in POST /v1/import. A key that pushes templates needs no asset scope for the images they use.
The asset object
Sizes are in bytes; width and height are pixel dimensions, or 0 when SendOps can’t read them.
{
"id": "0192f1a3-1c2b-7e44-9a01-7d2f6b5e4c3a",
"source_path": "static/logo.png",
"url": "https://cdn.acme.example/0192.../assets/3f9a...c1.png",
"content_hash": "3f9a...c1",
"content_type": "image/png",
"size_bytes": 20480,
"width": 280,
"height": 80,
"created_at": "2026-05-01T09:00:00Z",
"referenced_by": [
{ "id": "0192aa01-...", "name": "welcome" }
]
} source_path— the logical path templates reference. It is thepathsupplied on upload, and it is what{{asset "static/logo.png"}}resolves through.content_hash— the SHA-256 of the image bytes. Assets are content-addressed, so identical content shares a hash and a stored object.url— the public CDN URL, or""until your Edge stack is active (see the callout above).referenced_by— the templates whose body references this asset’s path. Always present: an empty array means the scan ran and found none, which is exactly what a caller about to delete the asset needs to know.tags— org-side tags/folders applied in the dashboard. Filter the collection with?tag=.
The list is ordered by source_path and cursor-paginated — follow pagination.next_cursor until it’s null. GET /v1/assets/{id} returns the same object for one image, or 404 when the handle isn’t an asset in your org.
{id} takes the asset’s logical source path as readily as its UUID — the same path you uploaded it at and the same one {{asset "..."}} names. A path contains slashes, so percent-encode it as a single path segment:
curl 'https://api.sendops.dev/v1/assets/images%2Flogo.png' -H "Authorization: Bearer sk_live_..." That is what makes a CI push self-contained: it uploads by path, and can read back or remove by the same path without first listing the library to translate one into an id. DELETE /v1/assets/{id} accepts both forms too.
Uploading
POST /v1/assets takes either a multipart/form-data body with a file part and a path field, or a JSON body carrying the bytes as base64. When path is omitted on the multipart form it defaults to the uploaded file’s name with directories stripped — send it explicitly for a nested path.
curl -s -X POST 'https://api.sendops.dev/v1/assets' \
-H "Authorization: Bearer sk_live_..." \
-F 'path=static/logo.png' \
-F 'file=@./dist/logo.png' {
"id": "0192f1a3-1c2b-7e44-9a01-7d2f6b5e4c3a",
"source_path": "static/logo.png",
"url": "https://cdn.acme.example/0192.../assets/3f9a...c1.png",
"content_hash": "3f9a...c1",
"content_type": "image/png",
"size_bytes": 20480,
"width": 280,
"height": 80,
"created_at": "2026-05-01T09:00:00Z",
"referenced_by": [],
"unchanged": true
} Re-running a push is free. The object key is the SHA-256 of the bytes, so uploading the same image at the same path writes nothing and answers 200 with unchanged: true. Uploading different bytes at the same path mints a new object and repoints the path; the previous object is retained, so a template already deployed against it keeps rendering the image it was deployed with.
The content type is decided by sniffing the bytes, never by the filename or a supplied content_type: PNG, JPEG, GIF and WebP are accepted, and anything else — including SVG, which can carry script and would be served from your own CDN origin — is 422. The image must be at most 5 MiB (413 above that).
Uploading does not redeploy anything
A deployed template embeds the URL it was deployed against, so replacing an image leaves the templates using it rendering the old one until they are deployed again — referenced_by lists exactly those templates. For the same reason, a CI push should upload images before writing templates. A bundle import already does them in that order.
Deleting
DELETE /v1/assets/{id} removes the row that makes a logical path resolve to an image, and answers 204. The stored object is retained — objects are content-addressed and mail already delivered points at them, so deleting one would break pictures in inboxes rather than reclaim anything that matters.
A delete is refused with 409 conflict while a live template still references the path; the response’s referenced_by names them. Repoint or remove the template, then retry.