Webhooks
Register a URL and Masko POSTs job results to it when a job completes or fails, so you do not have to poll.
Create a Webhook
curl -X POST https://api.masko.ai/v1/webhooks \
-H "Authorization: Bearer masko_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/api/masko-webhook",
"events": ["job.completed", "job.failed"]
}'
# Response:
# {
# "data": {
# "id": "wh_abc123",
# "url": "https://your-app.com/api/masko-webhook",
# "events": ["job.completed", "job.failed"],
# "secret": "whsec_k7x9m2p4q8r1...",
# "created_at": "2026-03-28T10:00:00Z"
# }
# }Webhook Payload
{
"id": "5f1c2a7e-8b0d-4c3e-9a51-2d7f6b8e4c19",
"object": "event",
"event": "job.completed",
"job_id": "0818df65-d092-5901-a506-3b2e64fb88ef",
"type": "animation",
"mascot_id": "c244e7b1-5655-5540-89a8-516b09bd4985",
"asset_ids": {
"image": "3c8ce568-35ce-59c2-b3f8-9fb7e4a16e52",
"transparent_image": "477db326-3fb1-53aa-aa09-d8377daa1ffa",
"video": "1b6520a1-a890-5a1c-ac87-15e9b14c4852",
"webm": "3db2d3be-a85a-5e9e-85fc-02100b19d8cb",
"hevc": "267afbae-2a4b-51c9-a172-47bb3017c32f"
},
"urls": {
"image": "https://assets.masko.ai/c244e7b1-5655-5540-89a8-516b09bd4985/image.png",
"transparent_image": "https://assets.masko.ai/c244e7b1-5655-5540-89a8-516b09bd4985/transparent.png",
"video": "https://assets.masko.ai/c244e7b1-5655-5540-89a8-516b09bd4985/video.mp4",
"webm": "https://assets.masko.ai/c244e7b1-5655-5540-89a8-516b09bd4985/video.webm",
"hevc": "https://assets.masko.ai/c244e7b1-5655-5540-89a8-516b09bd4985/video_hevc.mp4"
},
"file_sizes": {
"image": 412833,
"transparent_image": 298120,
"video": 2620007,
"webm": 1370194,
"hevc": 2397850
},
"timestamp": "2026-03-28T10:01:32Z"
}id is the event's UUID. Retries resend the same id and body, so timestamp is when the event was created. Each request also carries these headers:
| Header | Value |
|---|---|
Masko-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> |
Masko-Event-Id | The event ID, equal to id in the body |
X-Masko-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body |
X-Masko-Timestamp | The same unix seconds as t |
X-Masko-Event | The event name, such as job.completed |
Verify Signatures
Verify Masko-Signature, which signs the send time with the body so a captured request cannot be replayed. Compute the HMAC-SHA256 of <t>.<raw body> with your secret, compare it with v1 in constant time, and reject a t more than 5 minutes from your clock. Use the exact raw body, never re-serialized JSON.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const secret = process.env.MASKO_WEBHOOK_SECRET;
if (!secret) throw new Error('Set MASKO_WEBHOOK_SECRET');
const TOLERANCE_SECONDS = 5 * 60;
function verifyWebhook(rawBody, header) {
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || '');
if (!match) return false;
const [, t, v1] = match;
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const actual = Buffer.from(v1, 'hex');
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
}
// Register before any app.use(express.json()) middleware.
app.post('/api/masko-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyWebhook(req.body, req.get('Masko-Signature'))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Store event.id with a unique constraint and skip events you have already seen.
// Acknowledge only after your application has accepted the event durably.
return res.sendStatus(200);
});X-Masko-Signature signs only the body, so it does not protect against replays; prefer Masko-Signature.
Delivery and retries
A 2xx response within 10 seconds counts as delivered. Otherwise Masko retries the same event 6 more times, after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (about 21 hours in total), stopping on success or when the webhook is deleted or disabled.
An event can arrive more than once, for example when your response was lost. Deduplicate by event id (also in Masko-Event-Id), acknowledge promptly after durable acceptance, and reconcile through the job endpoint if events stop.
Each failed attempt adds one to consecutive_failures and a success resets it to 0. At 100, Masko disables the webhook (active: false); delete it and create a new one to resume.
Only jobs started with an API key send webhooks, so poll jobs started elsewhere, such as in Studio. Payload fields depend on the workflow: rely on id, object, event and job_id, and fetch the job for details.
Manage Webhooks
curl https://api.masko.ai/v1/webhooks \
-H "Authorization: Bearer masko_YOUR_API_KEY"
# Response:
# {
# "data": [
# {
# "id": "wh_abc123",
# "url": "https://your-app.com/api/masko-webhook",
# "events": ["job.completed", "job.failed"],
# "active": true,
# "consecutive_failures": 0,
# "created_at": "2026-03-28T10:00:00Z"
# }
# ]
# }curl -X DELETE https://api.masko.ai/v1/webhooks/wh_abc123 \
-H "Authorization: Bearer masko_YOUR_API_KEY"
# Response: 204 No Content