Skip to main content

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​

  1. Your plan must include the webhooks feature. Without it, no webhook is sent.
  2. Add an endpoint in the Mocart dashboard. Webhook endpoints are configured there, not through this API — there is no /webhook-endpoints resource 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.
  3. 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.created and campaign.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​

EventFires whendata
run.completedA run reaches a terminal status — SUCCEEDED, PARTIAL, FAILED, REFUSED, STOPPED, or EXPIRED.The Run object, as getRun returns it.
creative.approvedA creative is approved.The creative.
creative.rejectedA creative is rejected.The creative.
creative.updatedA creative's aspect ratio or metadata changes.The creative.
creative.deletedA creative is deleted — on its own, with its campaign, or by taking its product out of the campaign.The creative, with deletedAt set.
creative.restoredA deleted creative is restored.The creative, with deletedAt: null.
asset.updatedAn asset's favorited, notes or metadata changes.The asset.
asset.archivedAn asset is archived.The asset.
asset.unarchivedAn asset is unarchived.The asset.
asset.deletedAn asset is deleted.The asset, with deletedAt set.
asset.restoredA deleted asset is restored.The asset, with deletedAt: null.
campaign.createdA campaign is created.The campaign.
campaign.updatedA campaign's name or metadata changes.The campaign.
campaign.deletedA campaign is deleted.The campaign, with deletedAt set.
campaign.restoredA 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" }
}
FieldMeaning
typeThe event name from the table above.
timestampWhen the change happened, as an ISO 8601 time. Use it to order events.
dataThe resource — the same object the API returns for it, read with ?showDeleted=true, so deletedAt is always there (null while it is live).
previousAttributesOn *.updated events only: what each changed field was before. See below.
actorWho 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: aspectRatio and metadata.
  • asset.updated: favorited, notes and metadata.
  • campaign.updated: name and metadata.

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.kindThe change was made by
api_keyA call with an API key. apiKeyId is the id of that key.
userYou or a teammate, in the Mocart dashboard.
share_linkAn outside reviewer, through a review link.
staffMocart staff acting on your account.
partner_operatorA 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.