Receive signed webhooks
Webhooks notify your system when supported Glossi state changes. Use them to reduce polling latency, then retrieve current state from the API before making an irreversible downstream decision.
Current webhook delivery is fire-and-forget and does not have a durable retry history. Build consumers that tolerate missed and duplicate events.
Available Events
| Event | Description |
|---|---|
model.processed | Model upload processing completed successfully |
model.failed | Model processing failed |
project.created | A project was created |
job.complete | A job workflow completed successfully |
job.failed | A job workflow failed |
render.complete | A render completed successfully |
render.failed | A render failed |
Configure Your Webhook
You can create or update your webhook configuration using the API.
Create/Update Webhook
Endpoint:
PUT https://api.glossi.io/api/v1/webhooksHeaders:
| Header | Value |
|---|---|
X-API-Key | Your API key |
Content-Type | application/json |
Body:
{
"url": "https://your-app.com/webhooks/glossi",
"events": [
"model.processed",
"model.failed",
"project.created",
"job.complete",
"job.failed",
"render.complete",
"render.failed"
],
"enabled": true,
"description": "Production webhook for n8n"
}Response:
{
"id": "webhook-uuid",
"url": "https://your-app.com/webhooks/glossi",
"secret": "your-webhook-secret",
"events": ["model.processed", "model.failed", "project.created", "job.complete", "job.failed", "render.complete", "render.failed"],
"enabled": true,
"description": "Production webhook for n8n"
}Important Save the
secretvalue - you'll need it to verify webhook signatures. If you lose it, you can retrieve it again withGET /api/v1/webhooks/secret, or rotate it withPOST /api/v1/webhooks/regenerate-secret. Updating the webhook config does not change the secret.
Note If you omit
events, the webhook is subscribed torender.completeandrender.failedonly. Pass the full list explicitly if you want model, project, and job events too.
Get Current Webhook
Endpoint:
GET https://api.glossi.io/api/v1/webhooksHeaders:
| Header | Value |
|---|---|
X-API-Key | Your API key |
Response:
{
"configured": true,
"id": "webhook-uuid",
"url": "https://your-app.com/webhooks/glossi",
"events": ["model.processed", "render.complete"],
"enabled": true,
"description": "Production webhook",
"lastTriggeredAt": "2026-07-28T10:30:00.000Z",
"failureCount": 0
}If no webhook is configured, the response is simply
{ "configured": false }.
Delete Webhook
Endpoint:
DELETE https://api.glossi.io/api/v1/webhooksHeaders:
| Header | Value |
|---|---|
X-API-Key | Your API key |
Webhook Payloads
All webhooks follow this format:
{
"event": "event.name",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
// Event-specific data
}
}Each delivery includes these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Glossi-Signature | HMAC-SHA256 hex digest of the request body (see verification) |
X-Glossi-Event | The event name, e.g. render.complete |
X-Glossi-Timestamp | ISO 8601 timestamp, same value as timestamp in the payload |
model.processed
Sent when a model finishes processing and is ready to use.
{
"event": "model.processed",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"modelId": "model-uuid",
"name": "Chair Model",
"status": "READY",
"glbFilePath": "https://s3.../file.glb",
"thumbnailUrl": "https://s3.../file.jpg"
}
}model.failed
Sent when model processing fails.
{
"event": "model.failed",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"modelId": "model-uuid",
"name": "Chair Model",
"status": "FAILED",
"error": "Model conversion failed: unsupported format"
}
}project.created
Sent when a project is created. Note that the project fields are nested under a project key.
{
"event": "project.created",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"project": {
"projectId": "project-uuid",
"name": "chair.glb",
"modelIds": ["model-uuid"],
"templateId": "template-uuid"
}
}
}job.complete
Sent when a Jobs API workflow completes successfully.
{
"event": "job.complete",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"jobId": "job-uuid",
"status": "COMPLETE",
"results": [
{
"model": { "id": "model-uuid", "name": "Chair" },
"project": { "id": "project-uuid", "name": "Chair Project" }
}
]
}
}job.failed
Sent when a Jobs API workflow fails.
{
"event": "job.failed",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"jobId": "job-uuid",
"status": "FAILED",
"error": "Model processing timed out"
}
}render.complete
Sent when a render job completes successfully.
{
"event": "render.complete",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"jobId": "render-job-uuid",
"status": "COMPLETED",
"projectId": "project-uuid",
"renderIds": ["render-uuid-1", "render-uuid-2"]
}
}Note Renders started from the Glossi Studio UI (rather than the API) also trigger this event, with a different
datashape:{ "renderId": "...", "projectId": "...", "render": { "id", "name", "imageUrl", "fileType", "isVideo", "isPreview" } }- nojobIdorrenderIds. If your workspace uses both the Studio and the API, handle both shapes (branching on the presence ofjobIdworks well).
render.failed
Sent when a render job fails.
{
"event": "render.failed",
"timestamp": "2026-07-28T10:30:00.000Z",
"data": {
"jobId": "render-job-uuid",
"projectId": "project-uuid",
"error": "Render timed out"
}
}Verify Webhook Signatures
All webhook requests include an X-Glossi-Signature header. Always verify this signature to ensure the request came from Glossi and hasn't been tampered with.
The signature is an HMAC-SHA256 hex digest of the raw request body using your webhook secret.
Important Compute the HMAC over the exact bytes you received - never over a re-serialized copy of the parsed JSON. Different JSON encoders produce different byte output (Python's
json.dumpsinserts spaces, PHP'sjson_encodeescapes slashes), so re-encoding will produce a digest that never matches. Configure your framework to give you the raw body.
JavaScript/Node.js
const crypto = require("crypto")
const express = require("express")
const app = express()
function verifyWebhook(rawBody, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(signature || "")
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Use express.raw() on the webhook route so req.body is the raw bytes,
// not parsed JSON
app.post("/webhooks/glossi", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-glossi-signature"]
const secret = process.env.GLOSSI_WEBHOOK_SECRET
if (!verifyWebhook(req.body, signature, secret)) {
return res.status(401).send("Invalid signature")
}
// Parse only after verification
const { event, data } = JSON.parse(req.body.toString("utf8"))
console.log(`Received ${event}:`, data)
res.status(200).send("OK")
})Python
import hmac
import hashlib
import os
from flask import Flask, request
app = Flask(__name__)
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature or "", expected)
@app.route("/webhooks/glossi", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Glossi-Signature")
secret = os.environ["GLOSSI_WEBHOOK_SECRET"]
# request.get_data() is the raw request body - verify against that
if not verify_webhook(request.get_data(), signature, secret):
return "Invalid signature", 401
payload = request.get_json()
print(f"Received {payload['event']}: {payload['data']}")
return "OK", 200PHP
function verifyWebhook($rawBody, $signature, $secret) {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature ?? '');
}
// php://input is the raw request body - verify against it before decoding
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_GLOSSI_SIGNATURE'] ?? '';
$secret = getenv('GLOSSI_WEBHOOK_SECRET');
if (!verifyWebhook($rawBody, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
// Decode only after verification
$payload = json_decode($rawBody, true);
$event = $payload['event'];
$data = $payload['data'];Delivery Behavior
Webhook delivery is not guaranteed and should not be treated as exactly once. Specifics worth designing around:
- No retries. Each event is delivered with a single POST. If your endpoint is down or times out, that event is not re-sent.
- 10-second timeout. Your endpoint must respond within 10 seconds or the delivery counts as failed. Acknowledge immediately and process asynchronously.
- Circuit breaker. After 10 consecutive failed deliveries, Glossi stops sending webhooks to your endpoint. A successful delivery resets the counter. If the breaker has tripped, fix your endpoint and call
POST /api/v1/webhooks/reset-failuresto resume delivery. You can checkfailureCountviaGET /api/v1/webhooks.
Run a periodic reconciliation process for important workflows. Query the current model, job, project, render, or pipeline state and repair any downstream work that did not receive an event.
Best Practices
- Always verify signatures - Never trust webhook data without verifying the signature.
- Respond quickly - Acknowledge the request, then process it asynchronously.
- Handle duplicates - Use stable resource or event data to make downstream work idempotent.
- Reconcile missed events - Periodically retrieve current state from the API.
- Use HTTPS - Expose only a secure production endpoint.
- Keep your own delivery log - Record the signature, event, resource identifier, and processing result.
Testing Webhooks
For local development, use a tunneling service like ngrok to expose your local server:
# Start ngrok
ngrok http 3000
# Use the ngrok URL when configuring your webhook
# https://abc123.ngrok.io/webhooks/glossiYou can also use webhook testing services like webhook.site to inspect payloads without writing code.
Control every API step
Create models, confirm uploads, build projects, start renders, inspect status, retry failures, and retrieve outputs through individual endpoints.
Integrate VNTANA with Glossi
Connect a VNTANA workspace so tagged assets flow into Glossi for rendering and the finished renders return as a new asset version.