# Webhooks

> X3D sends no outbound webhooks and orders carry no callback URL, so polling GET /api/print/orders/{code} is the supported way to follow an order.

Source: https://x3dstudios.com/docs/api/webhooks  ·  Updated: 2026-10-16

X3D does not send webhooks. There is no field on an order for a callback URL, no page for registering an endpoint, no signing secret issued to API customers, and no code anywhere that posts an event to a customer's server. If you need to know when an order moves, you ask — poll GET /api/print/orders/{code}.

> **There is nothing to configure** — This page exists because "webhooks" is the first place developers look. If you are searching for where to paste your endpoint URL, stop looking: that setting does not exist, and no combination of fields on a submission will make us call you back.

## The webhook endpoints in X3D are inbound

Three paths in this codebase have webhook in their name or their job. All three receive traffic; none send it, and none are addressable by an API customer.

`POST /api/billing/webhook`

Payment events arriving from Stripe, verified against our own signing secret.

Auth: webhook

`POST /api/webhook`

A legacy alias kept for an already-configured URL. It forwards to the handler above.

Auth: webhook

`POST /api/order-status`

The print farm reporting progress on a website order, behind a shared internal secret.

Auth: webhook

| Endpoint | Who calls it | How it authenticates | Can you use it? |
| --- | --- | --- | --- |
| /api/billing/webhook | Stripe | A stripe-signature header verified against our webhook secret. No signature is a 400; a bad one is a 400. | No. Only Stripe holds a signature we accept. |
| /api/webhook | Stripe | Identical — it hands the request straight to the billing handler. | No. |
| /api/order-status | Our own print farm | An x-internal-key header compared against a secret held in our deployment. | No. The secret is not issued to customers, and it moves website orders, not API orders. |

> **Even the internal callback does not touch API orders** — /api/order-status updates the order records created by the website checkout and emails the customer about them. Orders placed through POST /api/print/orders are a separate record with its own lifecycle, and nothing in that internal path writes to them.

---

## Poll instead

`GET /api/print/orders/{code}`

The supported way to follow an order. Same key, same header as submitting.

Auth: api-key

Poll the code you got back at submission. The response carries status, a human-readable statusLabel, paid, the price, and a tracking object that stays null until the order ships.

| status | What has happened | Set by |
| --- | --- | --- |
| PENDING_PAYMENT | The card charge failed. Nothing will happen until it is paid. | Submission |
| RECEIVED | Paid, waiting for an operator to accept it. | The successful charge |
| ACCEPTED | Queued to a printer, not started. | An operator |
| PRINTING | On the plate now. | An operator |
| PRINTED | Off the plate, being inspected and packed. | Automatic when the last farm print job linked to the order completes, or an operator |
| SHIPPED | Handed to the carrier. tracking is populated. | An operator |
| DELIVERED | The carrier reported delivery. | The daily carrier check, or an operator |
| CANCELLED | Stopped before printing. | An operator |
| REJECTED | The farm declined the job, usually an unprintable file. | An operator |

> **Statuses can skip, and they can go backwards** — Nothing validates the transitions: an operator can set any of the eight non-payment values directly, so an order can jump from RECEIVED to SHIPPED between two polls, and could in principle move back a step. React to the status you read rather than assuming the one that ought to come next.

**bash**

```bash
CODE=X3D-K7M2QP

while :; do
  BODY=$(curl -sS -H "Authorization: Bearer $X3D_API_KEY" \
    "https://x3dstudios.com/api/print/orders/$CODE")

  STATUS=$(echo "$BODY" | jq -r .status)
  echo "$(date -u +%H:%M) $STATUS"

  case "$STATUS" in
    SHIPPED|DELIVERED|CANCELLED|REJECTED)
      echo "$BODY" | jq -r '.tracking // "no tracking"'
      break
      ;;
  esac

  sleep 300
done
```

**node**

```javascript
const BASE = "https://x3dstudios.com";
const FINISHED = new Set(["SHIPPED", "DELIVERED", "CANCELLED", "REJECTED"]);

async function follow(code, everyMs = 5 * 60_000) {
  for (;;) {
    const res = await fetch(`${BASE}/api/print/orders/${code}`, {
      headers: { Authorization: `Bearer ${process.env.X3D_API_KEY}` },
    });

    // 404 is "no such code" AND "not yours" — check the code you stored.
    if (!res.ok) {
      const { error } = await res.json();
      throw new Error(`X3D ${res.status}: ${error}`);
    }

    const order = await res.json();
    if (!order.paid) console.warn(`${code} is still unpaid`);
    if (FINISHED.has(order.status)) return order;   // tracking is set at SHIPPED

    await new Promise((r) => setTimeout(r, everyMs));
  }
}

const order = await follow("X3D-K7M2QP");
console.log(order.status, order.tracking);   // { number: "1Z…", carrier: "ups" }
```

> **Stop at SHIPPED, not DELIVERED** — SHIPPED is the point where tracking appears and the carrier takes over — from there, the carrier's own API knows more than we do. DELIVERED comes from a check we run once a day against the carrier, or from an operator, so it can trail the parcel's arrival by up to a day. An order whose carrier was recorded as Other is never checked, and reaches DELIVERED only by hand.

## How often to poll

- Every 5 minutes is a sensible default. Prints run for hours and most transitions are made by a person, so faster polling learns nothing sooner.
- Poll only orders that are still open. Once you see SHIPPED, CANCELLED or REJECTED, stop — nothing further will change that matters to you.
- The status endpoint has no rate limit in the application, so there is nothing to back off from and nothing to catch you either. Be considerate.
- Poll per order. There is no list endpoint on the REST API, so a hundred open orders is a hundred requests — space them out rather than firing them together. The MCP server's list_my_orders tool takes the same key and returns your 20 most recent orders in one call; see /docs/api/mcp.

## What arrives without asking

Email, and nothing else. When an API order is paid, a confirmation goes to every address in the order's email field (the account address unless the request named others, up to five) with the code, a one-line summary, the total charged and the shipping address. After that the same addresses get an email when the order is printed, when it ships (with tracking) and when it is delivered. Printed goes out on its own when the last farm print job for the order completes, and delivered when the daily carrier check sees the parcel arrive; the rest go out when an operator moves the order on. Each address gets its own message, so the people on an order do not see each other's addresses. Accepted and printing send nothing, a correction backwards sends nothing, and nothing is pushed to your systems.

> **Store the code at submission** — The order code is the only handle the REST API gives you. No REST endpoint lists your orders, and there is no way to look one up by file name, address or date. Write the code to your own records in the same transaction that triggered the submission. If you do lose one, the MCP tool list_my_orders (same key) and the /orders page (signed in as the key's account) both list API orders with their codes.

- [Order status reference](https://x3dstudios.com/docs/api/order-status) — Every field the status endpoint returns, including the tracking object.
- [Errors](https://x3dstudios.com/docs/api/errors) — What the 401s and 404s from the status endpoint actually mean.
- [Rate limits](https://x3dstudios.com/docs/api/rate-limits) — What is limited, what is not, and how the windows work.
- [Changelog](https://x3dstudios.com/docs/api/changelog) — API-visible changes, in case a field appeared or a price moved.

[Talk to us about your integration](https://x3dstudios.com/contact)

## Frequently asked

**Does the X3D API support webhooks?**

No. There is no outbound webhook system: no field to register a callback URL, no signing secret issued to customers, and no code that posts anything to a customer endpoint. Follow an order by polling GET /api/print/orders/{code}.

**What are the /api/webhook endpoints in X3D then?**

They are inbound. /api/billing/webhook receives signature-verified events from our payment processor, /api/webhook is a legacy alias for the same handler, and /api/order-status accepts progress reports from the print farm behind a shared secret. None can be subscribed to, and the secrets are ours, not yours.

**How often should I poll an X3D order?**

Every few minutes is plenty. A print runs for hours and most transitions are made by an operator, so a five-minute poll sees everything a one-second poll would. Nothing rate-limits the status endpoint today, which is a reason to be considerate rather than a reason not to be.

**Will X3D email me when my API order ships?**

Yes. Every address on the order gets the confirmation when it is paid, then an email when it is printed, when it ships (with the tracking number) and when it is delivered. Accepting the order and starting the print send nothing. Email is all that is sent: nothing is posted to your systems, so a program that needs to react to a change still polls.
