Managing campaigns
A campaign groups the creatives made for a set of your products. You can manage campaigns through the API:
POST /campaigns— create a campaign for some of your products.PATCH /campaigns/{id}— rename it and attach your ownmetadata.DELETE /campaigns/{id}— delete the campaign and its creatives. Nothing is destroyed.POST /campaigns/{campaignId}/undelete— restore it.?showDeleted=trueonGET /campaignsandGET /campaigns/{id}— read deleted campaigns too.
None of these spends credits. Every write returns the campaign as GET /campaigns/{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 campaigns:write
permission. Like every write key it must expire, within 365 days. campaigns:write implies read, so
the same key can read back what it changed. See Authentication.
Your plan must include campaigns. Without it, every campaign write answers 403 TIER_REQUIRED.
export API_KEY="mcf_74b5dd1e6a29150ab5ab6081d0b30ac043aadf7d2c948e80d330775d0b6382e8"
2. Create a campaign — POST /campaigns
Name the campaign, choose a brand, say what it produces, and list the products it covers:
curl -s -X POST https://api.mocart.io/api/public/v1/campaigns \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7c2e9f40-1a5d-4b86-93e7-d0f4a6c81b25" \
-d '{
"name": "Autumn launch",
"brandId": "brand_9c1e2f7a4b3d",
"assetType": "STATIC",
"format": "1:1",
"productIds": ["prod_8f2a1c9b4d6e", "prod_1a4c7e9b2d5f"],
"metadata": { "externalId": "cmp-2026-autumn" }
}'
| Field | Required | What it is |
|---|---|---|
name | yes | 1–200 characters after leading and trailing spaces are trimmed. |
brandId | yes | One of your brands. |
assetType | yes | What the campaign produces: STATIC (images), VIDEO, 3D or PLAYABLE. |
format | yes | The output aspect ratio: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9 or 21:9. A VIDEO campaign accepts only 16:9 and 9:16. |
productIds | yes | At least one product of that brand. Unlike the dashboard, an empty list does not mean "every product" — name the products you want. |
metadata | no | Your own string labels, up to 50 keys. See Metadata. |
Any other field is a 422. Idempotency-Key is required.
201 Created, with an ETag header, a Location header pointing at the new campaign, and the
campaign:
{
"object": "campaign",
"campaignId": "cmp_4d2a9e7c1b3f",
"name": "Autumn launch",
"status": "IN_PROGRESS",
"brandId": "brand_9c1e2f7a4b3d",
"creativeCount": 2,
"createdAt": "2026-10-01T08:12:40.000Z",
"metadata": { "externalId": "cmp-2026-autumn" }
}
What a new campaign contains
The campaign opens one empty creative per product. These creatives have the status NOT_GENERATED:
they hold a place for the product and have no image or video yet. Creating a campaign spends no
credits and starts no generation.
Because an empty creative has no media, it isn't returned by GET /creatives or GET /creatives/{id}
(see Managing creatives), and it doesn't trigger a creative webhook. It appears
there once it has been generated.
Retrying a create
Send the same Idempotency-Key with the same body and you get the same campaign back — never a second
one. A replay returns 201 with the same Location. The campaign's id is derived from the key, so a
replay always names the same campaign — even after you delete it: the replay then still answers 201,
with the campaign as it is now, including deletedAt. If you reuse a key for a different campaign, that's
409 IDEMPOTENCY_KEY_REUSED. See Idempotency.
Plan limits: 403 LIMIT_EXCEEDED
Your plan caps how many campaigns an account can have, and how many products one campaign can cover. Go
over either and the create is refused with 403, and nothing is created:
{
"type": "/errors/LIMIT_EXCEEDED",
"title": "Forbidden",
"status": 403,
"code": "LIMIT_EXCEEDED",
"detail": "Your plan allows 10 campaigns and this account already has 10.",
"instance": "/api/public/v1/campaigns",
"requestId": "a1b2c3d4-e5f6-4a3b-8c9d-0e1f2a3b4c5d",
"limitKey": "maxCampaigns",
"limit": 10,
"current": 10
}
limitKey is maxCampaigns or maxProductsPerCampaign; limit is what your plan allows and current
is where you stand. It is a 403, not a 429: a plan limit isn't a rate, so waiting won't help.
Delete a campaign you no longer need, name fewer products, or move to a plan with a higher limit. Deleted
campaigns don't count against the limit until you restore them.
3. Rename a campaign — PATCH /campaigns/{id}
Send name, metadata, or both. Nothing else is writable, and any other field is a 422.
curl -s -X PATCH https://api.mocart.io/api/public/v1/campaigns/cmp_4d2a9e7c1b3f \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Autumn launch — EU", "metadata": { "region": "eu" } }'
200 OK, with a fresh ETag and the campaign. A request that changes nothing — the same name, an empty
metadata, no body — is a 200 that records nothing. Idempotency-Key is optional.
Metadata
metadata is a map of your own string labels — an id from your system, a region, a batch name. Mocart
stores it and returns it; it has no other meaning. On POST you send the whole map. On PATCH what you
send is merged into the stored map, 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 PATCH may name up to 100 keys. A merge that would leave
more than 50 keys is a 422, and nothing is changed. null is never a value on POST.
metadata appears in the response only when the campaign has at least one key.
4. Delete a campaign — DELETE /campaigns/{id}
curl -s -X DELETE https://api.mocart.io/api/public/v1/campaigns/cmp_4d2a9e7c1b3f \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: e4a81c93-5b72-4d06-8f2a-39c7d0b1e654"
200 OK, with the campaign and deletedAt set:
{
"object": "campaign",
"campaignId": "cmp_4d2a9e7c1b3f",
"name": "Autumn launch — EU",
"status": "IN_PROGRESS",
"brandId": "brand_9c1e2f7a4b3d",
"creativeCount": 2,
"createdAt": "2026-10-01T08:12:40.000Z",
"metadata": { "externalId": "cmp-2026-autumn", "region": "eu" },
"deletedAt": "2026-10-01T09:45:03.000Z"
}
Deleting a campaign deletes every creative in it too. Nothing is destroyed: you can restore the campaign, and the creatives that went with it, with no time limit. Deleting spends and refunds no credits, and never cancels a run.
The campaign is deleted at once, and its creatives follow. Each creative records its own deletion, so
each one also sends a creative.deleted webhook.
Refusals: busy and still finishing
A delete can be refused with a 409, in three ways. In each, nothing changed:
CAMPAIGN_NOT_DELETABLE — the campaign is busy. It holds creatives that are QUEUED or
GENERATING. The problem body says how many in generatingCount:
{
"type": "/errors/CAMPAIGN_NOT_DELETABLE",
"title": "Conflict",
"status": 409,
"code": "CAMPAIGN_NOT_DELETABLE",
"detail": "Cannot delete a campaign with 3 creative(s) generating",
"instance": "/api/public/v1/campaigns/cmp_4d2a9e7c1b3f",
"requestId": "a1b2c3d4-e5f6-4a3b-8c9d-0e1f2a3b4c5d",
"generatingCount": 3
}
Wait for generation to finish, then retry. The same code, with "reason": "still_being_created" in place
of generatingCount, means the campaign's own creation is still being written. Try again in a few
seconds.
CAMPAIGN_CASCADE_PENDING — the previous change is still finishing. A delete or restore commits the
campaign immediately and finishes its creatives right after. If you change the campaign again while that
is still going on, you get this 409 with a Retry-After header. Wait that long and retry the same
request; it succeeds shortly.
IDEMPOTENCY_IN_PROGRESS / IDEMPOTENCY_KEY_REUSED — the usual key conflicts. See
Idempotency.
5. Restore a campaign — POST /campaigns/{campaignId}/undelete
curl -s -X POST https://api.mocart.io/api/public/v1/campaigns/cmp_4d2a9e7c1b3f/undelete \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2f9d6b18-7c04-4e53-a1b8-5d3e0c7f9a42" \
-d '{}'
200 OK, with the campaign as GET /campaigns/{id} returns it — no deletedAt. As on every POST,
send a body ({}): one with none is refused by the load balancer with 411.
- It restores the creatives that were deleted with the campaign. A creative you deleted on its own
before stays deleted — restore it with
undeleteon the creative. - The campaign's review links stay revoked.
- Restoring a campaign that is already live is a
200that changes nothing. - The plan limit applies again. A restored campaign counts against your plan's campaign limit like a
new one, so if you are at the limit, the restore is refused with
403 LIMIT_EXCEEDED— the same body as on create. - If the previous delete is still finishing, you get
409 CAMPAIGN_CASCADE_PENDING. WaitRetry-Afterand retry. - If the restore loses a race with other changes on your account, you get
503 SERVICE_UNAVAILABLEwithRetry-After. Nothing was restored; resend the same request with the same key.
Read deleted campaigns — ?showDeleted=true
curl -s "https://api.mocart.io/api/public/v1/campaigns?showDeleted=true" \
-H "Authorization: Bearer $API_KEY"
With showDeleted=true, every campaign in the response carries deletedAt: an ISO 8601 time for a
deleted campaign, null for a live one. Without it, deleted campaigns are hidden and the response has
no deletedAt. Any value other than true is treated as if you had left it out.
Guard a write with If-Match
GET /campaigns/{id} returns a strong ETag. Send it back as If-Match on PATCH or DELETE, and the
write only goes through if the campaign is still exactly as you read it. Otherwise: 412 PRECONDITION_FAILED, and nothing is changed.
The ETag covers the whole campaign as you read it — including creativeCount and status, not just
the fields you can edit. Those change by themselves: a creative finishing generation changes the
campaign's status and counts. So a 412 doesn't always mean someone edited your campaign. When you get
one, read the campaign again and retry with the new ETag — and if your change is still right, it
will now go through.
curl -s -X PATCH https://api.mocart.io/api/public/v1/campaigns/cmp_4d2a9e7c1b3f \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H 'If-Match: "b2e47d90c1a6f3858d0e2a9c4b7f1e63a5d8c0b9f2e4a7d1c6b3e8f0a9d2c5b7"' \
-d '{ "name": "Autumn launch — EU" }'
If-Matchis optional. Leave it out to write regardless of what changed.If-Match: *matches any campaign that exists.- Send the
ETagexactly as you received it, quotes included. A value starting withW/never matches. GET /campaigns/{id}?showDeleted=truereturns a weakETagyou can't use forIf-Match; read the campaign without the parameter.undeletedoesn't takeIf-Match.
Retries and Idempotency-Key
| Operation | Idempotency-Key | Same key, same request |
|---|---|---|
POST /campaigns | Required | 201, the campaign the key already created |
PATCH /campaigns/{id} | Optional | 200, the campaign; nothing recorded again |
DELETE /campaigns/{id} | Optional | 200, the original delete answer |
POST .../undelete | Required | 200, the campaign; nothing recorded again |
Without a key, a DELETE you retry after a lost response finds the campaign already deleted and gets
404; send a key, and the retry returns the original answer instead. Rules for the key itself (16–255
characters) 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 | POST /campaigns or 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 campaigns:write. | Mint a key with campaigns:write. |
403 | TIER_REQUIRED | Your plan doesn't include campaigns. | Upgrade the plan. |
403 | LIMIT_EXCEEDED | A create, or a restore, would put you over the plan's campaign limit, or a campaign over its products limit. limitKey, limit and current say which. | Delete a campaign, name fewer products, or upgrade. Don't retry unchanged. |
404 | CAMPAIGN_NOT_FOUND | No campaign with this id that you can change: it doesn't exist, belongs to another account, or is deleted (except for undelete, and for a DELETE retried under its Idempotency-Key). | Check the id against GET /campaigns. |
409 | CAMPAIGN_NOT_DELETABLE | The campaign has QUEUED or GENERATING creatives (generatingCount), or is still being created (reason). | Wait, then retry. |
409 | CAMPAIGN_CASCADE_PENDING | The campaign's previous delete or restore is still finishing. | Wait Retry-After, then retry the same request. |
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 campaign. Nothing was changed. | Read the campaign again and retry with the new ETag. |
422 | VALIDATION_ERROR | The body is invalid: an unknown field, an empty productIds, a VIDEO format other than 16:9 or 9:16, a bad name or metadata, or a brandId or product id that isn't yours. 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. |
503 | SERVICE_UNAVAILABLE | A create or a restore lost a race with other changes on your account. Nothing was changed. | Wait Retry-After, then resend the same request with the same key. |
A brandId or product id that doesn't exist and one that belongs to another account get the same
422: you can't probe for other accounts' ids.
Rate limits
Campaign creates, edits, deletes and restores share one budget of 60 requests per minute per key,
separate from reads and from creative and asset writes. Read the RateLimit headers rather than
hard-coding it — see Rate limits.
Webhooks
Campaign changes — from the API or the dashboard — can notify your own endpoint: campaign.created,
campaign.updated, campaign.deleted and campaign.restored. Deleting or restoring a campaign also
sends creative.deleted or creative.restored for each creative that changed. See
Webhooks.