Managing creatives
Besides reviewing a creative, you can change it and take it out of sight through the API:
PATCH /creatives/{id}(updateCreative) — set the delivery aspect ratio and attach your ownmetadata.DELETE /creatives/{id}(deleteCreative) — delete a creative. Nothing is destroyed: a deleted creative is hidden, and you can restore it at any time.POST /creatives/{creativeId}/undelete(undeleteCreative) — restore a deleted creative.?showDeleted=trueonGET /creatives(listCreatives) andGET /creatives/{id}(getCreativeById) — read deleted creatives too.
None of these spends credits. All three writes return the creative as GET /creatives/{id} returns
it, with an ETag header.
1. Mint a key
In the Mocart dashboard, go to Settings → API keys and create a key with the creatives:write
permission. Like every write key it must expire, within 365 days. creatives:write implies read,
so the same key can read back what it changed. It does not cover approve and reject — those need
creatives:review. See Authentication.
export API_KEY="mcf_74b5dd1e6a29150ab5ab6081d0b30ac043aadf7d2c948e80d330775d0b6382e8"
2. Edit a creative — PATCH /creatives/{id} (updateCreative)
Send only the fields you want to change. Both are optional, and any other field is a 422.
curl -s -X PATCH https://api.mocart.io/api/public/v1/creatives/crv_7b3e9a1c5d2f \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"aspectRatio": "9:16",
"metadata": { "externalId": "sku-100-story", "reviewer": null }
}'
200 OK, with an ETag header, and the creative:
{
"object": "creative",
"creativeId": "crv_7b3e9a1c5d2f",
"productId": "prod_8f2a1c9b4d6e",
"campaignId": "cmp_4d2a9e7c1b3f",
"brandId": "brand_9c1e2f7a4b3d",
"sku": "CPD-100",
"mediaType": "image",
"url": "https://storage.googleapis.com/.../crv_7b3e9a1c5d2f.png",
"thumbnailUrl": null,
"aspectRatio": "9:16",
"approvedAt": null,
"playerUrl": null,
"embedCode": null,
"metadata": { "externalId": "sku-100-story" }
}
aspectRatio
The delivery aspect ratio for this creative: one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5,
5:4, 9:16, 16:9, 1.91:1, 6:5 or 21:9. Any other value is a 422.
The response shows the new ratio at once. The image or video is re-cropped in the background, so
url can keep pointing at the old crop for a short while. Re-cropping is free. If you need the
finished crop, read the creative again later; its ETag changes when the media does.
metadata
metadata is a map of your own string labels — an id from your system, a batch name, a review
status. Mocart stores it and returns it; it has no other meaning.
A PATCH merges the map you send into the stored one, key by key:
| You send | Result |
|---|---|
"key": "value" | The key is added, or its value is replaced. |
"key": null | The key is removed. |
| A key you leave out | Untouched. |
"metadata": {} | Nothing changes. |
There is no "replace everything": to clear the map, send null for each key.
The limits: at most 50 keys in the stored map, key names of 1–40 characters (letters, digits, _
and -, and not starting with __), and values of up to 500 characters. One request may name up to
100 keys, so it can delete a full map and set a new one. A merge that would leave more than 50 keys is
a 422, and nothing is changed.
metadata appears in the response only when the creative has at least one key. A creative that has
never had metadata reads exactly as it did before.
A request that changes nothing — the same ratio, an empty metadata, or no body at all — is a 200
that records nothing.
3. Guard a write with If-Match
GET /creatives/{id} returns a strong ETag: a fingerprint of the creative as you just read it. Send
it back as If-Match on PATCH, DELETE or undelete, and the write only goes through if the
creative is still exactly that. If someone else changed it in between — a teammate in the dashboard,
another integration — you get 412 PRECONDITION_FAILED and nothing is changed.
curl -si https://api.mocart.io/api/public/v1/creatives/crv_7b3e9a1c5d2f \
-H "Authorization: Bearer $API_KEY" | grep -i '^etag'
# etag: "9c0f5d1e7a3b2c84d6e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6"
curl -s -X PATCH https://api.mocart.io/api/public/v1/creatives/crv_7b3e9a1c5d2f \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H 'If-Match: "9c0f5d1e7a3b2c84d6e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6"' \
-d '{ "metadata": { "externalId": "sku-100-story" } }'
On a 412, read the creative again, decide whether your change still makes sense, and retry with the
new ETag. Rules to know:
If-Matchis optional. Leave it out to write regardless of what changed.If-Match: *matches any creative that exists.- Send the
ETagexactly as you received it, quotes included. A value starting withW/never matches. - The
ETagonGET /creatives/{id}is only sent on the plain read.?expand=productreturns a weakETagthat is no use forIf-Match, and so does?showDeleted=trueon a live creative. A deleted creative read with?showDeleted=truedoes carry the strong one. - The
ETagthatapproveCreative,rejectCreativeand every write in this guide return is the same tag, so you can use it straight away without another read.
4. Delete a creative — DELETE /creatives/{id} (deleteCreative)
curl -s -X DELETE https://api.mocart.io/api/public/v1/creatives/crv_7b3e9a1c5d2f \
-H "Authorization: Bearer $API_KEY"
200 OK, with the creative, deletedAt set:
{
"object": "creative",
"creativeId": "crv_7b3e9a1c5d2f",
"productId": "prod_8f2a1c9b4d6e",
"campaignId": "cmp_4d2a9e7c1b3f",
"brandId": "brand_9c1e2f7a4b3d",
"sku": "CPD-100",
"mediaType": "image",
"url": "https://storage.googleapis.com/.../crv_7b3e9a1c5d2f.png",
"thumbnailUrl": null,
"aspectRatio": "9:16",
"approvedAt": null,
"playerUrl": null,
"embedCode": null,
"metadata": { "externalId": "sku-100-story" },
"deletedAt": "2026-10-01T08:30:12.000Z"
}
A deleted creative disappears from GET /creatives and GET /creatives/{id}. It is hidden, not destroyed, and there is no time limit on restoring it. Deleting spends
and refunds no credits, and never stops a run.
A creative that is still generating
A creative that is QUEUED or GENERATING can't be deleted yet:
{
"type": "/errors/CREATIVE_NOT_DELETABLE",
"title": "Conflict",
"status": 409,
"code": "CREATIVE_NOT_DELETABLE",
"detail": "The creative is GENERATING, so it cannot be deleted until it finishes. Read it again (GET /creatives/{id}) and retry once it has settled.",
"instance": "/api/public/v1/creatives/crv_2c8f5a9e1d7b",
"requestId": "a1b2c3d4-e5f6-4a3b-8c9d-0e1f2a3b4c5d",
"currentStatus": "GENERATING"
}
Nothing changed. currentStatus says what the creative is doing. Wait until generation finishes and
send the same request again — this is the one 409 here that is worth retrying, after a delay
that suits how long your generations take.
5. Restore a creative — POST /creatives/{creativeId}/undelete (undeleteCreative)
curl -s -X POST https://api.mocart.io/api/public/v1/creatives/crv_7b3e9a1c5d2f/undelete \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3b1d7f92-6c4a-4e08-9a15-d2e8b0c7f461" \
-d '{}'
200 OK, with the creative as GET /creatives/{id} returns it — no deletedAt. As with approve,
send a body ({}): a POST with none is refused by the load balancer with 411. Restoring a
creative that is already live is a 200 that changes nothing.
If the creative was deleted together with its campaign, and that campaign is still deleted, the
answer is 409 CREATIVE_CAMPAIGN_DELETED. Restore the campaign first — that brings back the creatives
that went with it. See Managing campaigns.
Read deleted creatives — ?showDeleted=true
GET /creatives and GET /creatives/{id} hide deleted creatives unless you ask:
curl -s "https://api.mocart.io/api/public/v1/creatives?showDeleted=true&campaignId=cmp_4d2a9e7c1b3f" \
-H "Authorization: Bearer $API_KEY"
With showDeleted=true, every creative in the response carries deletedAt: an ISO 8601 time for a
deleted creative, null for a live one. Without the parameter, the response is exactly what it was
before it existed, and has no deletedAt. Any value other than true is treated as if you had left
it out; it is never an error.
Retries and Idempotency-Key
| Operation | Idempotency-Key | Same key, same request |
|---|---|---|
PATCH /creatives/{id} | Optional | 200, the creative; nothing recorded again |
DELETE /creatives/{id} | Optional | 200, the original delete answer |
POST .../undelete | Required | 200, the creative; nothing recorded again |
Without a key a PATCH or DELETE simply runs. That is safe for an edit, which you can repeat, but a
DELETE you retry after a lost response finds the creative already deleted and gets 404 — send a
key, and the retry returns the original answer instead. Rules for the key itself (16–255 characters,
IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_IN_PROGRESS) are in Idempotency.
Handling each error
Every error is application/problem+json except 429. Branch on code.
| Status | code | What happened | What to do |
|---|---|---|---|
400 | IDEMPOTENCY_KEY_REQUIRED | undelete without an Idempotency-Key. | Add one. |
400 | INVALID_IDEMPOTENCY_KEY | The key is under 16 or over 255 characters, or the header was sent twice. | Send exactly one header, 16–255 characters. |
401 | — | The key is missing, unknown, revoked or expired, or the account no longer has API access. | Mint a new key. See Authentication. |
403 | API_TOKEN_SCOPE_DENIED | The key doesn't carry creatives:write. | Mint a key with creatives:write. Scopes can't be added to an existing key. |
404 | CREATIVE_NOT_FOUND | No creative with this id that you can change: it doesn't exist, belongs to another account, is deleted (for PATCH), or isn't a usable image or video. | Check the id against GET /creatives. Don't retry unchanged. |
409 | CREATIVE_NOT_DELETABLE | The creative is QUEUED or GENERATING; currentStatus says which. | Wait for generation to finish, then retry. |
409 | CREATIVE_CAMPAIGN_DELETED | The creative was deleted with its campaign, which is still deleted. | Restore the campaign first. |
409 | IDEMPOTENCY_KEY_REUSED | This key was already used for a different request. | Use a new key. |
409 | IDEMPOTENCY_IN_PROGRESS | An earlier attempt under this key hasn't finished. | Wait Retry-After, then resend the identical request with the same key. |
412 | PRECONDITION_FAILED | If-Match no longer matches: the creative changed since you read it. Nothing was changed. | Read the creative again and retry with the new ETag. |
422 | VALIDATION_ERROR | The PATCH body is invalid: an unknown field, an aspectRatio outside the list above, a bad metadata key or value, or more than 50 keys after the merge. errors[] names the field. | Fix the body. |
429 | — (plain-text body) | Too many writes in the current window. | Wait Retry-After, then resend. See Rate limits. |
503 | WRITES_PAUSED | Writes are paused for your account. Nothing changed and the key wasn't used. | Wait Retry-After, then resend with the same key. See Reviewing creatives. |
A creative with no public representation — a 3D or playable creative, or one that has no media yet —
answers 404 on every write, exactly as GET does. You can only change what you can read.
Rate limits
Creative edits, deletes and restores share one budget of 60 requests per minute per key, separate
from reads and from reviews. Read the RateLimit headers rather than hard-coding it — see
Rate limits.
The SDK version
The same flow using @mocart-io/api 1.5.0 or later.
It is adapted from docs-site/snippets/manage-creatives.ts in the repo, type-checked against the SDK
by npm run sdk:check.
import { randomUUID } from 'node:crypto';
import {
client,
getCreativeById,
updateCreative,
deleteCreative,
undeleteCreative,
type Creative,
} from '@mocart-io/api';
client.setConfig({
baseUrl: 'https://api.mocart.io/api/public/v1',
auth: () => process.env.MOCART_API_KEY,
});
/** Read the creative, then change its metadata only if nobody changed it since — retrying on a 412. */
async function setMetadata(creativeId: string, metadata: Record<string, string | null>): Promise<Creative> {
for (let attempt = 1; attempt <= 3; attempt++) {
const read = await getCreativeById({ path: { id: creativeId } });
const etag = read.response?.headers.get('ETag');
if (!read.data || !etag) {
throw new Error(`getCreativeById failed: ${typeof read.error === 'object' ? read.error?.code : 'no response'}`);
}
const { data, error } = await updateCreative({
path: { id: creativeId },
headers: { 'If-Match': etag },
body: { metadata },
});
if (data) {
return data;
}
// 412: the creative changed after we read it. Loop to read it again.
if (typeof error === 'object' && error.code === 'PRECONDITION_FAILED') {
continue;
}
throw new Error(`updateCreative failed: ${typeof error === 'object' ? error.code : 'rate limited'}`);
}
throw new Error('The creative kept changing; giving up.');
}
/** Delete with a key, so a retry after a lost response returns the original answer instead of a 404. */
async function removeCreative(creativeId: string): Promise<Creative> {
const { data, error } = await deleteCreative({
path: { id: creativeId },
headers: { 'Idempotency-Key': randomUUID() },
});
if (!data) {
// CREATIVE_NOT_DELETABLE carries currentStatus (QUEUED or GENERATING): wait, then try again.
throw new Error(`deleteCreative failed: ${typeof error === 'object' ? error.code : 'rate limited'}`);
}
return data;
}
async function restoreCreative(creativeId: string): Promise<Creative> {
const { data, error } = await undeleteCreative({
path: { creativeId },
headers: { 'Idempotency-Key': randomUUID() },
});
if (!data) {
throw new Error(`undeleteCreative failed: ${typeof error === 'object' ? error.code : 'rate limited'}`);
}
return data;
}
Webhooks
Every one of these changes — from the API, the dashboard or a review link — can notify your own
endpoint: creative.updated, creative.deleted and creative.restored. See
Webhooks.