Webhooks
Get notified when jobs complete instead of polling. Register a webhook URL and Masko sends a POST request with job results as soon as they are ready.
Create a Webhook
Register a webhook endpoint with the events you want to receive. The response includes a secret for verifying payloads.
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
When a subscribed event fires, Masko sends a POST request to your URL with a JSON body:
{
"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"
},
"timestamp": "2026-03-28T10:01:32Z"
}Every payload has an id (the event ID) and object: "event". The event ID is a UUID created once per event. Every retry of that event sends the same id and the same 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. It signs the time of sending together with the body, so a captured request cannot be replayed later. Compute an HMAC-SHA256 of <t>.<raw body> with the secret from when you created the webhook, compare it with v1 in constant time, and reject the request when t is more than 5 minutes away from your clock. Always sign the exact raw body, not JSON that has been parsed and re-serialized.
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 is also sent on every request. It signs only the body, without a time, so it does not protect against replays. Prefer Masko-Signature.
Delivery and retries
Masko treats any 2xx response as delivered. A different status, a network error, or no response within 10 seconds is a failed attempt. After a failed first attempt, Masko retries the same event 6 more times, waiting:
- 1 minute
- 5 minutes
- 30 minutes
- 2 hours
- 6 hours
- 12 hours
Retries stop as soon as one attempt succeeds, or when the webhook is deleted or disabled. The last retry happens about 21 hours after the event.
The same event can arrive more than once, for example when your server handled it but the response was lost. Deduplicate by the event id (also sent as Masko-Event-Id), acknowledge promptly after durable acceptance, and use the job endpoint to reconcile progress if events stop arriving.
Every failed attempt adds one to the webhook's consecutive_failures, and every successful delivery resets it to 0. When it reaches 100, Masko disables the webhook (active: false) and stops delivering to it. To resume, delete the webhook and create a new one.
Notifications are dispatched for jobs attributed to API keys. Use job polling for workflows without API-key attribution; see the Studio migration. Payload fields depend on the originating workflow; rely on id, object, event, and job_id, then fetch current job details when needed.
Manage Webhooks
List all your registered 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"
# }
# ]
# }Delete a webhook when you no longer need it:
curl -X DELETE https://api.masko.ai/v1/webhooks/wh_abc123 \
-H "Authorization: Bearer masko_YOUR_API_KEY"
# Response: 204 No Content