Webhooks
A webhook tells your server that something happened, so you don't have to poll for it. Mocart sends one when a run finishes, and when a creative, asset or campaign changes — whether the change came from the API, the Mocart dashboard or a review link.
Polling stays fully supported: a webhook is an optimization, never the only path to a result. Use
GET /runs/{id} (getRun) and GET /runs/{id}/items (listRunItems) — see Runs — and the
read endpoints to catch up whenever you need to.
Turn webhooks on
- Your plan must include the
webhooksfeature. Without it, no webhook is sent. - Add an endpoint in the Mocart dashboard. Webhook endpoints are configured there, not through this
API — there is no
/webhook-endpointsresource to call. Add, disable or remove an endpoint, and view recent delivery attempts, from the same place. The signing secret is shown once per endpoint. - Choose the event types when you add the endpoint. The endpoint portal requires you to select the
event types it subscribes to — an endpoint with no event types selected is rejected. Pick every event
from the table below you want delivered, for example
campaign.createdandcampaign.deleted. An event type you didn't select isn't sent to that endpoint.
Events from before your endpoint existed are not sent later. Mocart only delivers an event if, when
it processes the change (about a minute after it happens), your account has the webhooks feature and
has set up its webhook endpoint. A change made before that point is never replayed — read the current
state from the API instead. The same goes for a period when your plan didn't include the feature.
Events
| Event | Fires when | data |
|---|---|---|
run.completed | A run reaches a terminal status — SUCCEEDED, PARTIAL, FAILED, REFUSED, STOPPED, or EXPIRED. | The Run object, as getRun returns it. |
creative.approved | A creative is approved. | The creative. |
creative.rejected | A creative is rejected. | The creative. |
creative.updated | A creative's aspect ratio or metadata changes. | The creative. |
creative.deleted | A creative is deleted — on its own, with its campaign, or by taking its product out of the campaign. | The creative, with deletedAt set. |
creative.restored | A deleted creative is restored. | The creative, with deletedAt: null. |
asset.updated | An asset's favorited, notes or metadata changes. | The asset. |
asset.archived | An asset is archived. | The asset. |
asset.unarchived | An asset is unarchived. | The asset. |
asset.deleted | An asset is deleted. | The asset, with deletedAt set. |
asset.restored | A deleted asset is restored. | The asset, with deletedAt: null. |
campaign.created | A campaign is created. | The campaign. |
campaign.updated | A campaign's name or metadata changes. | The campaign. |
campaign.deleted | A campaign is deleted. | The campaign, with deletedAt set. |
campaign.restored | A deleted campaign is restored. | The campaign. |
An event is only sent for something you could read through the API. A creative that has no image or
video yet (a new NOT_GENERATED creative), or a 3D or playable one, is not readable through the API,
so changes to it send nothing. Creating a creative never sends an event; creating the campaign that
holds it does.
Deleting or restoring a campaign changes its creatives too, and each of them sends its own
creative.deleted or creative.restored.
The payload
Every event is a JSON object in the same envelope:
{
"type": "creative.updated",
"timestamp": "2026-10-01T08:30:12.418Z",
"data": {},
"previousAttributes": {},
"actor": { "kind": "api_key", "apiKeyId": "9xQ2mT7vLd4KpW1sHnZa" }
}
| Field | Meaning |
|---|---|
type | The event name from the table above. |
timestamp | When the change happened, as an ISO 8601 time. Use it to order events. |
data | The resource — the same object the API returns for it, read with ?showDeleted=true, so deletedAt is always there (null while it is live). |
previousAttributes | On *.updated events only: what each changed field was before. See below. |
actor | Who made the change. See Who made the change. |
The event's id is not in the body. It is the webhook-id header on the delivery — see
Deduplicate on webhook-id.
An event's data carries nothing a read key couldn't already read from the API: no prompt text, no
internal ids beyond ones already public.
data is the resource when the notification is sent
data is rendered when Mocart sends the notification, not when the change happened. That is usually
within a minute, but it can be later when a delivery is being retried. So if the resource changed again
in between, data already shows the newer state. timestamp and previousAttributes still describe
the change this event is about. When you need the exact current state, read the resource from the API.
previousAttributes
On an *.updated event, previousAttributes holds the value each changed field had before the
change, under its public name:
creative.updated:aspectRatioandmetadata.asset.updated:favorited,notesandmetadata.campaign.updated:nameandmetadata.
For metadata it is the whole previous map, not only the keys that changed. A field that didn't change
is not listed, and neither is one that data doesn't carry — for example metadata after the last key
was removed. previousAttributes.aspectRatio is the ratio that was set on the creative itself, so it is
null when the creative had none and was showing one derived from its campaign.
Examples
A creative's aspect ratio changed through the API:
{
"type": "creative.updated",
"timestamp": "2026-10-01T08:30:12.418Z",
"data": {
"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,
"deletedAt": null
},
"previousAttributes": { "aspectRatio": "1:1" },
"actor": { "kind": "api_key", "apiKeyId": "9xQ2mT7vLd4KpW1sHnZa" }
}
A creative deleted in the dashboard:
{
"type": "creative.deleted",
"timestamp": "2026-10-01T09:02:47.091Z",
"data": {
"object": "creative",
"creativeId": "crv_2c8f5a9e1d7b",
"productId": "prod_1a4c7e9b2d5f",
"campaignId": "cmp_4d2a9e7c1b3f",
"brandId": "brand_9c1e2f7a4b3d",
"sku": "CPD-200",
"mediaType": "video",
"url": "https://storage.googleapis.com/.../crv_2c8f5a9e1d7b.mp4",
"thumbnailUrl": "https://storage.googleapis.com/.../crv_2c8f5a9e1d7b.jpg",
"aspectRatio": "9:16",
"approvedAt": "2026-09-30T14:20:05.000Z",
"playerUrl": "https://mocart.io/p/crv_2c8f5a9e1d7b",
"embedCode": "<iframe src=\"https://mocart.io/p/crv_2c8f5a9e1d7b\" ...></iframe>",
"deletedAt": "2026-10-01T09:02:47.000Z"
},
"actor": { "kind": "user" }
}
An asset favorited through the API:
{
"type": "asset.updated",
"timestamp": "2026-10-01T08:41:19.250Z",
"data": {
"object": "asset",
"assetId": "ast_3d8f1b6a9c2e",
"productId": "prod_8f2a1c9b4d6e",
"type": "STATIC",
"url": "https://storage.googleapis.com/.../ast_3d8f1b6a9c2e.png",
"thumbnailUrl": null,
"favorited": true,
"status": "active",
"name": "Front view",
"width": 2048,
"height": 2048,
"durationSec": null,
"playerUrl": null,
"embedCode": null,
"createdAt": "2026-09-12T10:04:31.000Z",
"deletedAt": null
},
"previousAttributes": { "favorited": false },
"actor": { "kind": "api_key", "apiKeyId": "9xQ2mT7vLd4KpW1sHnZa" }
}
A campaign renamed through the API:
{
"type": "campaign.updated",
"timestamp": "2026-10-01T09:15:33.702Z",
"data": {
"object": "campaign",
"campaignId": "cmp_4d2a9e7c1b3f",
"name": "Autumn launch — EU",
"status": "IN_PROGRESS",
"brandId": "brand_9c1e2f7a4b3d",
"creativeCount": 2,
"createdAt": "2026-10-01T08:12:40.000Z",
"deletedAt": null
},
"previousAttributes": { "name": "Autumn launch" },
"actor": { "kind": "api_key", "apiKeyId": "9xQ2mT7vLd4KpW1sHnZa" }
}
And the original event, run.completed, whose data is the Run object and which carries no
previousAttributes or actor:
{
"type": "run.completed",
"timestamp": "2026-09-28T12:00:24.000Z",
"data": {
"runId": "run_a26e94e8f3ae95313f4d46c5",
"object": "run",
"status": "SUCCEEDED",
"total": 2,
"credits": { "estimated": 8, "rateCardVersion": "2026-08-01" },
"counts": { "succeeded": 2, "failed": 0, "refused": 0, "stopped": 0, "pending": 0 },
"createdAt": "2026-09-28T12:00:00.000Z",
"finalizedAt": "2026-09-28T12:00:24.000Z"
}
}
Which changes send an event
The event is about the change, not about how it was made. A change sends the same event whether it came from:
- The API, with an API key.
- The Mocart dashboard — including bulk actions, such as excluding several creatives at once.
- A review link, when an outside reviewer approves or rejects a creative.
So an integration that changes things through the API also hears about what people do in the dashboard. A change that turns out to be a no-op — setting a field to the value it already has — changes nothing and sends nothing.
Who made the change
actor says who did it. It never carries a name, an email address or a user id.
actor.kind | The change was made by |
|---|---|
api_key | A call with an API key. apiKeyId is the id of that key. |
user | You or a teammate, in the Mocart dashboard. |
share_link | An outside reviewer, through a review link. |
staff | Mocart staff acting on your account. |
partner_operator | A partner operator acting on your account. |
apiKeyId is only present when kind is api_key.
Ignore your own changes
When your integration writes through the API, the resulting event comes back to your endpoint too. To
avoid reacting to your own change, compare actor.apiKeyId with the id of the key your integration
writes with, and skip the event when they match:
if (event.actor.kind === 'api_key' && event.actor.apiKeyId === MY_API_KEY_ID) {
return; // our own change
}
Changes made with a different key, in the dashboard or through a review link still reach you.
Ordering
Events can arrive out of order. Mocart doesn't guarantee that events for one resource arrive in the
order the changes were made, and a retried event arrives later than events that followed it. Order by
timestamp, never by arrival. And remember that data is the resource as of sending, so the latest
data you receive is the best picture of the resource, whichever event carried it.
Deduplicate on webhook-id
The same event can be delivered more than once (a retry after your endpoint timed out, for example).
webhook-id is the event's own id and is stable across deliveries, so track ids you've already
processed (a set, a database column with a unique constraint) and skip a repeat rather than re-handling
it.
Delivery and retries
Webhooks are delivered at least once, typically within a minute or two of the change.
If Mocart can't hand an event over for delivery, it tries again after one minute, then two, four and so on, waiting at most an hour between attempts. It gives up on an event 24 hours after the change happened. You can always catch up by reading the resource from the API.
Answer 2xx quickly from your endpoint, and do the real work afterwards. The dashboard shows the recent
delivery attempts for each endpoint.
Verifying a webhook
Every delivery carries three headers that follow the Standard Webhooks specification:
webhook-id: msg_2xJ9k3fN7pQaR8s
webhook-timestamp: 1799337600
webhook-signature: v1,g0hM9SsE+OTPJTGeGpU9r1UbHtLK9m1KMr8Fb6
Verify every delivery before trusting its body, and reject a webhook-timestamp older than five
minutes — that's the replay window this API's signatures are designed around.
Node
npm install standardwebhooks
import { Webhook } from 'standardwebhooks';
import express from 'express';
const app = express();
const webhookSecret = process.env.MOCART_WEBHOOK_SECRET; // shown once, in the dashboard, per endpoint
const wh = new Webhook(webhookSecret);
// Verification needs the raw request body — don't let a JSON body-parser consume it first.
app.post('/webhooks/mocart', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body, {
'webhook-id': req.header('webhook-id'),
'webhook-timestamp': req.header('webhook-timestamp'),
'webhook-signature': req.header('webhook-signature'),
});
} catch (err) {
return res.status(400).send('invalid signature');
}
if (!seen(req.header('webhook-id'))) {
handle(event);
markSeen(req.header('webhook-id'));
}
res.status(200).end();
});
wh.verify throws if the signature doesn't match or the timestamp is outside the tolerance
window, so a caught exception is your reject-and-400 signal.
Using the SDK
@mocart-io/api doesn't wrap standardwebhooks
(verification has nothing to do with the API client — you already have this dependency from the
example above) but it does export the payload types. This is
docs-site/snippets/verify-webhook.ts in the repo, type-checked against the SDK by
npm run sdk:check:
import { Webhook } from 'standardwebhooks';
import type { RunCompletedEvent } from '@mocart-io/api';
export interface MocartWebhookHeaders {
'webhook-id': string;
'webhook-timestamp': string;
'webhook-signature': string;
}
export function verifyMocartWebhook(rawBody: string, headers: MocartWebhookHeaders, secret: string): RunCompletedEvent {
const webhook = new Webhook(secret);
return webhook.verify(rawBody, headers) as RunCompletedEvent;
}
The write events aren't in the SDK as event types, but their data is the Creative, Asset and
Campaign object the SDK already exports. This is docs-site/snippets/handle-write-webhook.ts, also
type-checked by npm run sdk:check. It verifies the delivery, drops a repeat, drops the echo of your
own change, and then acts on the event:
import { Webhook } from 'standardwebhooks';
import type { Asset, Creative } from '@mocart-io/api';
export interface WriteWebhookHeaders {
'webhook-id': string;
'webhook-timestamp': string;
'webhook-signature': string;
}
export interface WebhookActor {
kind: 'user' | 'api_key' | 'staff' | 'partner_operator' | 'share_link';
/** Present only when `kind` is `api_key`: the id of the key that made the change. */
apiKeyId?: string;
}
interface Envelope<Type extends string, Data> {
type: Type;
/** When the change happened (ISO 8601) — use it to order changes, not the order of arrival. */
timestamp: string;
/** The resource as it is when the notification is sent; `deletedAt` is always present. */
data: Data;
/** On `*.updated` only: the previous value of each changed field. */
previousAttributes?: Partial<Data>;
actor: WebhookActor;
}
export type CreativeEvent = Envelope<
'creative.approved' | 'creative.rejected' | 'creative.updated' | 'creative.deleted' | 'creative.restored',
Creative
>;
export type AssetEvent = Envelope<
'asset.updated' | 'asset.archived' | 'asset.unarchived' | 'asset.deleted' | 'asset.restored',
Asset
>;
export type WriteEvent = CreativeEvent | AssetEvent;
function isCreativeEvent(event: WriteEvent): event is CreativeEvent {
return event.type.startsWith('creative.');
}
/** The id of the API key this integration writes with — its own changes come back as webhooks too. */
const OWN_API_KEY_ID = process.env.MOCART_API_KEY_ID;
/** `webhook-id`s already handled. Keep these in a database with a unique constraint in production. */
const handled = new Set<string>();
export function handleWriteWebhook(rawBody: string, headers: WriteWebhookHeaders, secret: string): void {
// Throws when the signature is wrong or the timestamp is outside the tolerance window.
const event = new Webhook(secret).verify(rawBody, headers) as WriteEvent;
// At-least-once delivery: the same event can arrive twice. `webhook-id` is stable per event.
const eventId = headers['webhook-id'];
if (handled.has(eventId)) {
return;
}
handled.add(eventId);
// Echo suppression: skip the change this integration just made itself.
if (event.actor.kind === 'api_key' && event.actor.apiKeyId === OWN_API_KEY_ID) {
return;
}
if (isCreativeEvent(event)) {
// `data` is the creative as it is now — `deletedAt` is non-null when it is deleted.
process.stdout.write(
`${event.type}: creative ${event.data.creativeId} (deletedAt ${event.data.deletedAt ?? 'none'})\n`,
);
return;
}
process.stdout.write(`${event.type}: asset ${event.data.assetId} (status ${event.data.status})\n`);
}
The snippet covers creative and asset events; a campaign event has the same envelope with a
Campaign as data.