Webhooks
Zupertry sends a signed HTTP POST to your webhook URL when a job completes or fails. Webhooks are the recommended way to receive results — more reliable than polling.
Setup
Provide your webhook URL when submitting a job:
{
"model_image_url": "https://...",
"garment_image_url": "https://...",
"webhook_url": "https://your-app.com/webhooks/zupertry"
}
Or set a default webhook URL for your entire organisation in the console under Settings → Webhook URL. The per-request webhook_url overrides the org-level default.
Event types
| Event | When sent |
|---|
job.completed | Job finished successfully, output image is ready |
job.failed | Job failed after all retries, credit refunded |
Payload shape
{
"type": "job.completed",
"data": {
"job_id": "job_abc123",
"workspace_id": "ws_xyz",
"org_id": "org_def456",
"status": "completed",
"output_url": "https://storage.googleapis.com/zupertry-outputs/...",
"credits_consumed": 1,
"created_at": "2026-03-19T10:00:00.000Z",
"completed_at": "2026-03-19T10:00:05.231Z"
}
}
For job.failed:
{
"type": "job.failed",
"data": {
"job_id": "job_abc123",
"status": "failed",
"error": "Vertex AI returned an unexpected response format",
"credits_consumed": 0,
"created_at": "2026-03-19T10:00:00.000Z"
}
}
Signature verification
Every webhook request includes an X-Zupertry-Signature header. This is an HMAC-SHA256 of the raw request body, signed with your webhook signing secret.
Always verify the signature before processing a webhook. Skip this and anyone can forge events to your endpoint.
Node.js
const crypto = require('crypto');
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // must be the raw Buffer, not parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader, 'hex'),
Buffer.from(expected, 'hex')
);
}
// Express example
app.use('/webhooks/zupertry', express.raw({ type: 'application/json' }));
app.post('/webhooks/zupertry', (req, res) => {
const valid = verifySignature(
req.body,
req.headers['x-zupertry-signature'],
process.env.ZUPERTRY_WEBHOOK_SECRET
);
if (!valid) return res.status(401).json({ error: 'Invalid signature' });
const event = JSON.parse(req.body.toString());
// handle event...
res.json({ received: true });
});
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/zupertry', methods=['POST'])
def webhook():
sig = request.headers.get('X-Zupertry-Signature', '')
secret = os.environ['ZUPERTRY_WEBHOOK_SECRET'].encode()
expected = hmac.new(secret, request.data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json(force=True)
print(f"Event: {event['type']}", event['data']['job_id'])
return jsonify({'received': True})
Retry logic
If your endpoint returns a non-2xx status (or doesn’t respond within 10 seconds), Zupertry retries the delivery:
| Attempt | Delay |
|---|
| 1st retry | 60 seconds |
| 2nd retry | 5 minutes |
| After 3 failures | Marked failed, visible in Logs |
Always respond 200 OK as quickly as possible (within the 10-second window). Do any heavy processing asynchronously — add the event to a queue and process it later.
Idempotency
Your endpoint may receive the same event more than once (network timeouts cause retries even after successful delivery). Always make your handler idempotent by using job_id as a deduplication key:
const processedJobs = new Set(); // use Redis or database in production
app.post('/webhooks/zupertry', (req, res) => {
// ... signature verification ...
const event = JSON.parse(req.body.toString());
const jobId = event.data.job_id;
if (processedJobs.has(jobId)) {
return res.json({ received: true, duplicate: true }); // already handled
}
processedJobs.add(jobId);
// process the event...
res.json({ received: true });
});
Reading webhook logs
All delivery attempts are logged in the Zupertry console under Logs → Webhook logs.
Each row shows:
- Delivery attempt number
- HTTP status returned by your endpoint
- Response time (ms)
- Timestamp
Click any row to expand the full request payload and response body.
Testing webhooks locally
Use ngrok to expose your local server:
ngrok http 3000
# → Forwarding: https://abc123.ngrok.io -> localhost:3000
Use the ngrok URL as your webhook URL when submitting test jobs.