Webhooks
Receive signed notifications when models and orders change.
Set up an endpoint
A business administrator adds HTTPS endpoints under Developers → Webhooks and selects at least one event type for each endpoint. To stop deliveries, an administrator disables or deletes the endpoint. Other members can review endpoints and deliveries. Personal accounts do not receive webhooks.
Set up your endpoint before the activity you want to receive. Earlier activity is not automatically backfilled.
Each delivery is a POST request with a JSON body. Source IP addresses are not published; authenticate deliveries by their signatures, not an IP allowlist.
Events
| Event | When |
|---|---|
order.created | Order accepted from a quote: awaiting_payment and unpaid. |
payment.requires_action | The customer must authenticate. Resume the order's payment. |
payment.processing | Payment submitted; the outcome is pending. |
payment.paid | Payment captured. order.confirmed follows for awaiting_payment orders. |
payment.failed | Payment attempt failed. Check payment.error before starting a new attempt. |
order.confirmed | Verified payment confirmed the order. |
order.in_production | Staff or fulfillment started manufacturing. Customer cancellation is no longer available. |
order.on_hold | Staff paused the order. The reason is the message shown to the customer; GET the order for hold.code. |
order.resumed | Staff released a hold; the status returns to its prior value. |
order.shipped | The whole order was handed to the carrier. GET the order for shipment tracking details. |
order.delivered | Staff recorded delivery. GET the order for shipment.delivered_at. |
order.cancelled | Order cancelled. Payment events report any refund. |
payment.cancelled | Payment attempt cancelled or voided without a charge. |
payment.refund_pending | Full refund requested after cancellation. |
payment.refunded | The payment provider confirmed the full refund. |
payment.refund_failed | The refund failed. Support follows up; no new charge is needed. |
model.ready | The model became ready: CalculatePrices, quotes and orders accept it at its saved size, including any required Model repair price. |
model.action_required | Processing finished but the model cannot be priced. status_reasons explains how to recover, for example by resizing or replacing the file. |
model.failed | The model's status changed to failed: intake or inspection failed terminally. Upload a corrected file and replace the source under the same model ID, or create a separate model. |
order.expired | Unpaid order reached expires_at with no active payment attempt. Late payment success is refunded without confirming the order. |
Order events mirror the order’s events[] entries and share their IDs.
Payload
Webhook body
{
"id": "evt_00000000000000000000000000000006",
"type": "order.confirmed",
"created_at": "2026-09-29T18:01:00.000000Z",
"data": {
"object": "order",
"id": "OM-3VFAH2ZH",
"external_id": "PO-1042",
"sequence": 4,
"status": "confirmed",
"payment": {
"status": "paid"
},
"reason": null,
"updated_at": "2026-09-29T18:01:00.000000Z"
}
}Payloads summarize status only. GET the order for shipment tracking and hold details.
Order event fields
id string · required
Same as the matching order events[].id. Stable across retries; deduplicate on it.
type string · required
Stable event name; handle unknown additions.
Allowed values: order.created, order.expired, order.confirmed, order.in_production, order.shipped, order.delivered, order.on_hold, order.resumed, order.cancelled, payment.requires_action, payment.processing, payment.paid, payment.failed, payment.cancelled, payment.refund_pending, payment.refunded, payment.refund_failed
created_at string (date-time) · required
Event time; matches events[].at.
data object · required
Order status when the event occurred. Excludes recipient, buyer and metadata; retrieve the order for details.
data fields
object string · required
Always: "order"
id string · required
Min length: 11 · Max length: 11
external_id string · optional
Present when supplied at order creation.
Max length: 255
sequence integer · required
The event's events[].sequence. Ignore events at or below the last sequence processed for this order.
Minimum: 1
status string · required
Order lifecycle: awaiting_payment, expired, confirmed, in_production, shipped, delivered, on_hold or cancelled. Staff set in_production, shipped, delivered and on_hold. Customer cancellation is allowed only in awaiting_payment or confirmed, subject to repair-coverage guards.
Allowed values: awaiting_payment, expired, confirmed, in_production, shipped, delivered, on_hold, cancelled
payment object · required
payment fields
status string · required
Order payment state. Refund states describe full-order refunds.
Allowed values: unpaid, requires_action, processing, paid, failed, cancelled, refund_pending, refunded, refund_failed
reason string | null · required
The event's reason, when one applies.
updated_at string (date-time) · required
Model event fields
id string · required
Stable across retries. Deduplicate on this value.
type string · required
Model readiness events, sent once per status change. Handle unknown additions.
Allowed values: model.ready, model.action_required, model.failed
created_at string (date-time) · required
Event time; matches data.updated_at.
data object · required
Model readiness when the event occurred. Retrieve the model for results; compare updated_at to discard older events.
data fields
object string · required
Always: "model"
id string · required
Min length: 11 · Max length: 11
external_id string · optional
Present when supplied at model creation.
status string · required
Public readiness. ready: CalculatePrices, quotes and orders accept the model at its saved size, including any required Model repair price. processing: inspection, measurements or the repair estimate are still running. action_required: processing finished but the model cannot be priced; status_reasons says why and how to recover. failed: processing failed; replace the file. Poll until status is not processing, or subscribe to model.ready.
Allowed values: processing, ready, action_required, failed
status_reasons array · required
status_reasons item fields
code string · required
Why the model is not ready. A failed model reports its processing error code.
Allowed values: inspection_pending, measurements_pending, repair_estimate_pending, measurements_unavailable, volume_unavailable, invalid_model_package, unreadable_model, missing_required_assets, unsupported_format, unsupported_model_features, invalid_geometry, processing_limit_exceeded, processing_timeout, inspection_unavailable, processing_failed, storage_unavailable
message string · required
Human-readable explanation; not a stable identifier.
mesh_check object · required
mesh_check fields
status string · required
Allowed values: pending, processing, completed, failed
error object · optional
Processing failure included in a successful model lookup (HTTP 200). Correct source problems by uploading a new file and replacing the existing model's source, or by creating a separate model. For persistent service failures, contact support with the model ID. A terminally failed model can retry with PATCH at the same print size and If-Match; this queues a new processing revision without uploading again. Completed evidence may remain visible, but manufacturing_estimate is unavailable and pricing/quotes return pricing_unavailable while failed. Late inspection results do not clear this model-level error or emit a completion event.
error fields
code string · required
Allowed values: invalid_model_package, unreadable_model, missing_required_assets, unsupported_format, unsupported_model_features, invalid_geometry, processing_limit_exceeded, processing_timeout, inspection_unavailable, processing_failed, storage_unavailable
message string · required
Recovery instructions. Do not branch on message text.
updated_at string (date-time) · required
New event types, fields and enum values can appear. Ignore unknown fields, and acknowledge verified events you do not handle with a 2xx response.
Verify signatures
During the preview, deliveries use svix-id, svix-timestamp and svix-signature headers. Later deliveries can use the Standard Webhooks names webhook-id, webhook-timestamp and webhook-signature with identical values, so accept both. The ID header identifies the delivery message, not the event.
Save the endpoint’s signing secret (whsec_…) from webhook settings in your server environment. Compute HMAC-SHA256 over the ID header, timestamp header and raw request body joined by periods, keyed with the base64-decoded secret after whsec_. Base64-encode the result and compare it in constant time with each space-separated v1, value in the signature header. Reject timestamps more than five minutes from the current time.
Receive a webhook
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["OTTOMFG_WEBHOOK_SECRET"].removeprefix("whsec_")
KEY = base64.b64decode(SECRET)
TOLERANCE_SECONDS = 300
def verified(headers, body):
# Accept webhook- (Standard Webhooks) and svix- (preview) header names.
def header(name):
return headers.get(f"webhook-{name}", headers.get(f"svix-{name}", ""))
timestamp = header("timestamp")
if not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = f"{header('id')}.{timestamp}.".encode() + body
digest = hmac.new(KEY, signed, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return any(
hmac.compare_digest(value[3:].encode(), expected)
for value in header("signature").split()
if value.startswith("v1,")
)
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verified(self.headers, body):
self.send_response(400)
self.end_headers()
return
event = json.loads(body)
# Acknowledge quickly; process asynchronously.
summary = {"id": event.get("id"), "type": event.get("type")}
print(json.dumps(summary), flush=True)
self.send_response(204)
self.end_headers()
port = int(os.environ.get("PORT", "8080"))
HTTPServer(("127.0.0.1", port), Receiver).serve_forever()
After a secret rotation, deliveries carry signatures for the old and new secrets for a limited time. Accept any matching v1, value.
The examples use only the standard library, for local testing. Verify the raw request bytes before your framework parses JSON (e.g. express.raw(), Django request.body).
Respond and retry
Return any 2xx status within 15 seconds, then process the event asynchronously. Other statuses, redirects and timeouts are failures.
A failed delivery is retried 7 more times: after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. That is 8 attempts over about 27 hours.
An endpoint failing for five days is disabled. After fixing it, re-enable it and recover missed deliveries in webhook settings.
Delivery is at least once and can be out of order. Deduplicate on the payload id. For orders, ignore an event whose data.sequence is at or below the last one processed for that order. For models, compare data.updated_at. Retrieve the order or model for details or to reconcile its current state.
Test your endpoint
- Save the Python example as
receive-webhook.pyor the Node.js example asreceive-webhook.mjs. - Forward a public HTTPS URL to port 8080 with a tunnel such as
ngrok http 8080. A localhost URL cannot receive deliveries directly. - In webhook settings, add an endpoint with the tunnel URL, select its event types and copy its signing secret.
Start the receiver with that secret. It listens on 127.0.0.1:8080; to use another port, set
PORTand forward that port instead.Run the receiver
export OTTOMFG_WEBHOOK_SECRET="YOUR_SIGNING_SECRET" python3 receive-webhook.py- On the endpoint’s Testing tab, send an example of a subscribed event type. Confirm HTTP 204 in its delivery attempts and the event’s
idandtypein the receiver output. - Trigger a real event of a subscribed type and confirm it arrives. A model that becomes ready produces
model.ready; terminal failure producesmodel.failed.
Example events carry fixed sample IDs that do not match resources in your account; repeated examples reuse those IDs.
API v1 preview