API v1 preview

# 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.

[Open webhook settings](https://ottomfg.com/account?view=webhooks)

## 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.

[Change policy](https://ottomfg.com/docs/changelog.md)

## 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

1. Save the Python example as `receive-webhook.py` or the Node.js example as `receive-webhook.mjs`.
2. Forward a public HTTPS URL to port 8080 with a tunnel such as `ngrok http 8080`. A localhost URL cannot receive deliveries directly.
3. In webhook settings, add an endpoint with the tunnel URL, select its event types and copy its signing secret.
4. Start the receiver with that secret. It listens on 127.0.0.1:8080; to use another port, set `PORT` and forward that port instead.

   Run the receiver

   ```
   export OTTOMFG_WEBHOOK_SECRET="YOUR_SIGNING_SECRET"
   python3 receive-webhook.py
   ```
5. 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 `id` and `type` in the receiver output.
6. Trigger a real event of a subscribed type and confirm it arrives. A model that becomes ready produces `model.ready`; terminal failure produces `model.failed`.

Example events carry fixed sample IDs that do not match resources in your account; repeated examples reuse those IDs.

[Create and poll a model](https://ottomfg.com/docs/models.md)

API v1 preview

Source: https://ottomfg.com/docs/webhooks
