Webhooks
Receive a signed HTTPS request when a PSD render, upload, video, photo-mockup create, or photo-mockup render job finishes.
Overview
Register one or more endpoints in your dashboard and SudoMock will POST a signed JSON payload to them when your jobs complete. Webhooks are free on every plan. Polling GET /api/v1/jobs/{uuid} remains the source of truth, and webhooks are a convenience layer on top.
Manage endpoints in the dashboard
whsec_), shown once when you create or rotate it.Events
| Event | When it fires |
|---|---|
| render.succeeded | An image render job finished successfully. |
| render.failed | An image render job failed. |
| upload.succeeded | A PSD upload finished parsing into a mockup. |
| video.succeeded | A video job finished successfully. |
| video.failed | A video job failed. |
| photo_mockup.ready | A product photo became a reusable photo mockup. |
| photo_mockup.rejected | A product photo was not suitable for a photo mockup. |
| photo_mockup.failed | A photo mockup creation job failed unexpectedly. |
| photo_mockup_render.succeeded | An asynchronous photo-mockup render finished successfully. |
| photo_mockup_render.failed | An asynchronous photo-mockup render failed. |
| webhook.test | Fired by the "Send test" action in the dashboard. |
Choose All events (an endpoint with no explicit event filter) to also receive events added in the future. The video.succeeded and video.failed events cover video jobs; render.succeeded, render.failed and upload.succeeded cover the async render and upload jobs you track with GET /api/v1/jobs/{job_id}. A failed upload is delivered as render.failed (there is no upload.failed event), so subscribe to render.failed if you ingest PSDs.
The photo_mockup.* events cover mockup creation. The photo_mockup_render.* events cover a render submitted with is_async: true. See the exact payload for each event in the Photo Mockups webhook reference.
Endpoints created before these names were introduced are pinned to the legacy spelling of the same five events (2d_mockup.ready, 2d_mockup.rejected, 2d_mockup.failed, 2d_render.succeeded, 2d_render.failed, with kind spelled 2d_create or 2d_render) and keep receiving them unchanged. The endpoint object's event_naming field (legacy or current) says which spelling an endpoint receives; new endpoints default to current. Either spelling is accepted in event_types and in the event_type delivery filter. To move an existing endpoint, PATCH it with { "event_naming": "current" } once your handler reads the new names.
Payload & headers
Headers
1X-SudoMock-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a082X-SudoMock-Timestamp: 17189000003Content-Type: application/json
Body
1{2 "event": "render.succeeded",3 "job_id": "c315f78f-d2c7-4541-b240-a9372842de94",4 "kind": "render",5 "status": "succeeded",6 "result_url": "https://cdn.sudomock.com/renders/c315f78f.png",7 "error": null,8 "created_at": "2026-06-21T10:00:00Z"9}
Verifying signatures
Every delivery carries X-SudoMock-Signature and X-SudoMock-Timestamp. The signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex-encoded. Always compute the HMAC over the raw request body (not a re-serialized object), compare it in constant time, and reject timestamps outside a small window to prevent replays.
1import crypto from 'crypto'23// Use the RAW request body (e.g. express.raw), not parsed JSON.4function verifySudoMockWebhook(req, secret) {5 const signature = req.header('X-SudoMock-Signature')6 const timestamp = req.header('X-SudoMock-Timestamp')7 const rawBody = req.body.toString('utf8')89 // Reject replays older than 5 minutes.10 const age = Math.floor(Date.now() / 1000) - Number(timestamp)11 if (!timestamp || Math.abs(age) > 300) return false1213 const expected = crypto14 .createHmac('sha256', secret)15 .update(`${timestamp}.${rawBody}`)16 .digest('hex')1718 return crypto.timingSafeEqual(19 Buffer.from(signature || '', 'hex'),20 Buffer.from(expected, 'hex')21 )22}
Always verify before trusting a payload
Managing endpoints via the API
Everything you can do from the dashboard you can also do programmatically against the /api/v1/webhook-endpoints resource. These routes accept either a dashboard Authorization: Bearer <JWT> or an x-api-key, so you can create, rotate, test and inspect endpoints from your own backend.
| Method & path | What it does |
|---|---|
| POST /api/v1/webhook-endpoints | Create an endpoint. Body: { url (https), description?, event_types[], event_naming? (default current) }. Returns 201 with the full whsec_ secret, shown once. |
| GET /api/v1/webhook-endpoints | List your endpoints (secrets masked as whsec_****<last4>). |
| GET /api/v1/webhook-endpoints/{id} | Get one endpoint. |
| PATCH /api/v1/webhook-endpoints/{id} | Update url, description, event_types, event_naming, or enabled. |
| DELETE /api/v1/webhook-endpoints/{id} | Hard-delete the endpoint (its deliveries cascade). Returns 204. |
| POST /api/v1/webhook-endpoints/{id}/rotate-secret | Issue a new signing secret. Returns the full whsec_ secret once. |
| POST /api/v1/webhook-endpoints/{id}/test | Send a synthetic webhook.test through the real signed path. Returns 202 { status, event_type, job_id }. |
| GET /api/v1/webhook-endpoints/{id}/deliveries | List delivery attempts for one endpoint. Query: status?, event_type?, cursor?, limit (1-200, default 50). |
| GET /api/v1/webhook-endpoints/events | Recent deliveries across all your endpoints. Query: status?, event_type?, cursor?, limit (1-200, default 100). |
| POST /api/v1/webhook-endpoints/{id}/deliveries/{delivery_id}/replay | Replay a single delivery with the same idempotency key. Returns 202 { status, delivery_id }. |
| POST /api/v1/webhook-endpoints/{id}/deliveries/replay-failed | Replay every failed/dead delivery for the endpoint. Returns 202 { status, count }. |
Delivery pagination
The delivery and events feeds use an opaque cursor. Make the first request without cursor. When another page exists, the response includes X-Webhook-Next-Cursor. Send that exact value as the next request's cursor. When the header is absent, the list is complete.
1GET /api/v1/webhook-endpoints/events?limit=1002X-Webhook-Next-Cursor: eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0yNFQxMDowMDowMFoifQ34GET /api/v1/webhook-endpoints/events?limit=100&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0yNFQxMDowMDowMFoifQ
Endpoint object
event_types is the list of event names this endpoint subscribes to; an empty array means all events (including ones added later). event_naming is the spelling of the photo-mockup events this endpoint receives. The secret is only returned in full on create and rotate-secret; every other read masks it.
1{2 "id": "we_8f3a...",3 "url": "https://your-domain.com/hooks/sudomock",4 "secret": "whsec_****a1b2",5 "description": "Production listener",6 "event_types": ["render.succeeded", "video.succeeded"],7 "event_naming": "current",8 "enabled": true,9 "created_at": "2026-06-21T10:00:00Z",10 "updated_at": null11}
Delivery object
1{2 "id": "wd_2b6f...",3 "endpoint_id": "we_8f3a...",4 "job_id": "c315f78f-d2c7-4541-b240-a9372842de94",5 "event_type": "render.succeeded",6 "status": "pending",7 "http_status": 200,8 "attempt": 1,9 "last_error": null,10 "created_at": "2026-06-21T10:00:01Z",11 "updated_at": null12}
1# Create an endpoint subscribed to all events (empty event_types)2curl -X POST "https://api.sudomock.com/api/v1/webhook-endpoints" \3 -H "Content-Type: application/json" \4 -H "x-api-key: sm_your_api_key" \5 -d '{6 "url": "https://your-domain.com/hooks/sudomock",7 "description": "Production listener",8 "event_types": []9 }'1011# Send a test delivery through the signed path12curl -X POST "https://api.sudomock.com/api/v1/webhook-endpoints/we_8f3a.../test" \13 -H "x-api-key: sm_your_api_key"
Store the secret on create
whsec_ secret is returned only once, on create and on rotate-secret. Save it when you receive it; afterwards it is always masked. You need it to verify signatures (see above).Retries & replay
Failed deliveries are retried automatically. You can inspect every attempt (with its HTTP status, attempt count and error) and replay a single delivery from the deliveries panel in your dashboard. Treat job_id + event as the delivery's idempotency key, persist it before applying side effects, and return a 2xx response only after processing succeeds. A replayed delivery may arrive with result_url set to null; when that happens, fetch the result by polling GET /api/v1/jobs/{job_id}.