Skip to main content

Reviewing creatives

A creative generated under a campaign waits in PENDING until someone decides whether it's good enough to use. You can make that decision in the Mocart dashboard, or through the API:

  • POST /creatives/{creativeId}/approve (approveCreative) — PENDING → APPROVED.
  • POST /creatives/{creativeId}/reject (rejectCreative) — PENDING → REJECTED, with an optional reason.

Neither one spends credits. Both need a key with the creatives:review scope and an Idempotency-Key header, and both return the reviewed creative.

1. Mint a write key​

In the Mocart dashboard, go to Settings → API keys, create a key with the creatives:review permission, and choose when it expires: 30, 90 or 365 days. A write key must expire, and never more than 365 days out. creatives:review implies read, so the same key can also list and fetch the creatives it reviews. The Permissions column on that page shows what each of your keys can do.

A key's scopes are fixed when it's created. To give an integration more or less access, create a new key with the scopes you want and revoke the old one. See Authentication for scopes, expiry and rotation.

export API_KEY="mcf_74b5dd1e6a29150ab5ab6081d0b30ac043aadf7d2c948e80d330775d0b6382e8"

2. Find what needs a decision — GET /creatives (listCreatives)​

GET /creatives returns APPROVED creatives unless you ask for another status, so ask for PENDING:

curl -s "https://api.mocart.io/api/public/v1/creatives?status=PENDING&campaignId=cmp_4d2a9e7c1b3f" \
-H "Authorization: Bearer $API_KEY"

Each row's creativeId is the id the review operations take. See Pagination for walking past the first page.

3. Approve — POST /creatives/{creativeId}/approve (approveCreative)​

curl -s -X POST https://api.mocart.io/api/public/v1/creatives/crv_7b3e9a1c5d2f/approve \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c2d8e-9a4b-4c1e-8f3d-2b7a6e1c9d40" \
-d '{}'

Approve takes no fields, but send a body anyway: {} with Content-Type: application/json, as above. A POST with no body and no Content-Length header is refused with 411 Length Required by the load balancer in front of the API, before it ever reaches Mocart. That's what curl -X POST without -d sends. The SDK and fetch send Content-Length: 0 on an empty POST, so they aren't affected.

200 OK, with an ETag header, and the creative — the same body GET /creatives/{id} (getCreativeById) returns:

{
"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": "1:1",
"approvedAt": "2026-09-30T09:15:02.000Z",
"playerUrl": "https://mocart.io/p/crv_7b3e9a1c5d2f",
"embedCode": "<iframe src=\"https://mocart.io/p/crv_7b3e9a1c5d2f\" ...></iframe>"
}

The creative object has no status field. approvedAt records when the creative was reviewed, and is set by a reject as well as an approve. A 200 from approveCreative means the creative is approved; to check a creative's status later, filter the list by it (GET /creatives?status=APPROVED, or REJECTED).

The ETag is the same strong tag GET /creatives/{id} returns for the creative, so you can send it back as If-Match on an edit or a delete (see Managing creatives) without reading the creative again.

4. Reject — POST /creatives/{creativeId}/reject (rejectCreative)​

curl -s -X POST https://api.mocart.io/api/public/v1/creatives/crv_2c8f5a9e1d7b/reject \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9e4a1b7c-3d2f-4e8a-b5c1-6f0d2a8e4b19" \
-d '{ "rejectionReason": "Logo is cropped at the top edge." }'

rejectionReason is optional: 1–500 characters after leading and trailing whitespace is trimmed. To give no reason, leave the field out and send {}. Don't send "" or null, and don't add any other field. Any of those is a 422. The response is the same 200 + ETag + creative as approve.

Already reviewed​

Repeating a review the creative already has — approving an APPROVED creative, or rejecting a REJECTED one — is a 200 that changes nothing. That makes a review safe to repeat, even with a new Idempotency-Key, whether the earlier decision came from your integration or from someone in the dashboard.

The opposite review is a conflict: approving a REJECTED creative, or rejecting an APPROVED one, is 409 CREATIVE_NOT_PENDING, and nothing changes. The problem body carries currentStatus, the status the creative is actually in, so you can reconcile without another read:

{
"type": "/errors/CREATIVE_NOT_PENDING",
"title": "Conflict",
"status": 409,
"code": "CREATIVE_NOT_PENDING",
"detail": "The creative is REJECTED, not PENDING, so it cannot take this review. Read it again (GET /creatives/{id}) before deciding what to do.",
"instance": "/api/public/v1/creatives/crv_2c8f5a9e1d7b/approve",
"requestId": "a1b2c3d4-e5f6-4a3b-8c9d-0e1f2a3b4c5d",
"currentStatus": "REJECTED"
}

Treat it as a decision someone else already made, not something to retry.

Retries and Idempotency-Key​

Every review needs an Idempotency-Key header (16–255 characters). Generate one per logical review — "approve creative X" — and reuse it for every retry of that review:

  • Same key, same request — the same creative, the same action and the same reason: 200 with the creative as it stands now, and the review isn't recorded a second time.
  • Same key, a different request — another creative, the other action, or a different rejectionReason: 409 IDEMPOTENCY_KEY_REUSED. Use a new key for a new review.
  • Same key while the first attempt is still in flight: 409 IDEMPOTENCY_IN_PROGRESS with Retry-After. Wait, then resend the identical request with the same key.
  • No header: 400 IDEMPOTENCY_KEY_REQUIRED. Malformed (too short, too long, or sent twice): 400 INVALID_IDEMPOTENCY_KEY.

A key is only bound once a review goes through. A request that's refused — a 404, a 409 CREATIVE_NOT_PENDING, a 422, a 503 WRITES_PAUSED — doesn't use up its key. The full rules, shared with POST /runs (createRun), are in Idempotency.

Handling each error​

Every error below is application/problem+json except 429 — see Errors. Branch on code.

StatuscodeWhat happenedWhat to do
400IDEMPOTENCY_KEY_REQUIREDNo Idempotency-Key header.Add one.
400INVALID_IDEMPOTENCY_KEYThe 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, or have the account owner restore access. See Authentication.
403API_TOKEN_SCOPE_DENIEDThe key is valid but doesn't carry creatives:review — typically a read-only key, or one minted with creatives:write, which edits and deletes but doesn't review.Mint a key with creatives:review. Scopes can't be added to an existing key.
404CREATIVE_NOT_FOUNDNo creative with this id that you can review: it doesn't exist, was deleted, belongs to another account, or isn't a usable image or video (the same creatives GET /creatives/{id} can't return). All of these get the same response.Check the id against GET /creatives. Don't retry unchanged.
409CREATIVE_NOT_PENDINGThe creative was already reviewed the other way; currentStatus says how.Reconcile. Don't retry.
409IDEMPOTENCY_KEY_REUSEDThis key was already used for a different review.Use a new key for this review.
409IDEMPOTENCY_IN_PROGRESSAn earlier attempt under this key hasn't finished.Wait Retry-After, then resend the identical request with the same key.
422VALIDATION_ERRORThe reject body is invalid: an empty or over-500-character rejectionReason, or an unknown field. errors[] names the field.Fix the body.
429— (plain-text body)Too many writes in the current window.Wait Retry-After, then resend with the same key. See Rate limits.
503WRITES_PAUSEDWrites are paused for your account — see below.Wait Retry-After, then resend with the same key.

Paused writes: 503 WRITES_PAUSED​

Mocart can pause API writes — for every account, or for a single one — for example while an integration is misbehaving. While writes are paused, every approve and reject returns 503 WRITES_PAUSED with a Retry-After header, and nothing changes. Reads are never paused: GET /creatives and every other GET keep working, and so does reviewing in the dashboard.

A paused request doesn't use up its Idempotency-Key. Honour Retry-After, then resend the identical request with the same key; once writes resume, it goes through as though the paused attempts never happened. A retry of a review that had already succeeded before the pause gets its 200 even while writes are paused.

Rate limits​

Writes have their own budget, separate from reads: by default 60 requests/min per key, 300 per account and 600 per IP. A burst of reviews can never use up your read budget, or the other way round. Mocart can tune these limits at runtime, so read the RateLimit headers each response carries instead of hard-coding numbers — see Rate limits.

The SDK version​

The same flow using @mocart-io/api 1.2.0 or later. This is adapted from docs-site/snippets/review-creatives.ts in the repo, kept honest by CI: npm run sdk:check type-checks it against the SDK on every change to either.

import { randomUUID } from 'node:crypto';
import { client, listCreatives, approveCreative, rejectCreative, type Creative } from '@mocart-io/api';

client.setConfig({
baseUrl: 'https://api.mocart.io/api/public/v1',
auth: () => process.env.MOCART_API_KEY,
});

/** Codes that mean "wait `Retry-After`, then resend the identical request with the same key". */
const RETRY_SAME_KEY = new Set(['WRITES_PAUSED', 'IDEMPOTENCY_IN_PROGRESS', 'RATE_LIMITED']);
const MAX_ATTEMPTS = 5;

type Review = { action: 'approve' } | { action: 'reject'; rejectionReason?: string };

/** Creatives waiting for a decision — `GET /creatives` defaults to `APPROVED`, so ask for `PENDING`. */
async function pendingCreatives(campaignId: string): Promise<Creative[]> {
const { data, error } = await listCreatives({ query: { status: 'PENDING', campaignId } });
if (error || !data) {
const reason = typeof error === 'string' ? error : (error?.code ?? 'no page returned');
throw new Error(`listCreatives failed: ${reason}`);
}
return data.data;
}

async function reviewCreative(creativeId: string, review: Review): Promise<Creative> {
// ONE key per logical review, generated once and reused for every retry of it.
const idempotencyKey = randomUUID();
const request = { path: { creativeId }, headers: { 'Idempotency-Key': idempotencyKey } };

for (let attempt = 1; ; attempt++) {
const { data, error, response } =
review.action === 'approve'
? await approveCreative(request)
: await rejectCreative({
...request,
body: review.rejectionReason === undefined ? {} : { rejectionReason: review.rejectionReason },
});

if (data) {
// 200: reviewed now, already in that state (a no-op), or a replay of this key.
return data;
}

// A 429 body is plain text, not a problem+json object — narrow before reading `.code`.
const code = typeof error === 'string' ? 'RATE_LIMITED' : error?.code;
if (code === undefined || !RETRY_SAME_KEY.has(code) || attempt >= MAX_ATTEMPTS) {
// CREATIVE_NOT_PENDING carries the creative's actual status — reconcile, don't retry.
const currentStatus = typeof error === 'object' ? error.currentStatus : undefined;
throw new Error(`${review.action} failed: ${code ?? 'no response'}${currentStatus ? ` (${currentStatus})` : ''}`);
}
const retryAfterSeconds = Number(response?.headers.get('Retry-After') ?? '1');
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
}
}

approveCreative takes no body: the SDK sends an empty POST with Content-Length: 0, which the load balancer accepts.

Key expiry and rotation​

A creatives:review key stops working the moment it expires: every request made with it after that returns 401. Fourteen days before expiry, the account owner gets an email naming the key. To rotate without downtime:

  1. Create a new key with the same scopes and a new expiry.
  2. Switch your integration over to it.
  3. Revoke the old key from Settings → API keys.

A revoked key fails with 401 on its very next request, so revoke only after you've confirmed the new key works. Scopes and expiry can't be edited on an existing key; changing either means a new key.