openapi: 3.1.0 info: title: POD Mfg Partner API version: v1 summary: Sell POD Mfg products from your own store, site or ERP. description: | The POD Mfg Partner API lets a brand that doesn't sell on Shopify connect its own system: read your published products and variants (with your retail and wholesale prices), map your own SKUs, send orders (we charge your card on file and make them exactly like a Shopify order), and receive signed webhooks when orders and products change. **Phase 1:** `GET /me`, `GET /products`, `GET /products/{id}`, `GET /variants/{id}`, `PATCH /variants/{id}` (`external_sku` only). **Phase 2:** `POST /orders`, `GET /orders`, `GET /orders/{id}`, `POST /orders/estimate`, `POST /orders/{id}/cancel`, `POST /orders/{id}/retry_payment`. **Phase 3:** `/webhook_endpoints` (create, list, get, update, delete, rotate the secret), `GET /events`, `GET /events/{id}`, `POST /events/{id}/resend`, and the webhooks themselves (see `webhooks`). **Phase 4 (this version of the spec):** test-mode simulations `POST /orders/{id}/simulate/ship`, `…/simulate/payment_failed`, `…/simulate/cancel`. **Start here:** the quickstart "Your first order in 15 minutes" at https://podmf.com/docs/partner-api-quickstart (test key, products, order, webhook, signature check). **Webhooks.** Add an endpoint (here or on the Developers page) and pick the events it wants (`["*"]` = all). Live endpoints must be https; every endpoint must be a public address (no private, loopback, link-local or internal hosts; redirects are not followed). Each delivery is a `POST` of the event envelope `{ id, type, created_at, livemode, api_version, data }` with `PODMFG-Signature: t=,v1=." keyed with your endpoint's secret>`, `PODMFG-Event-Id` and `PODMFG-Event-Type`. Verify the signature over the raw body and reject a `t` more than 5 minutes off (samples: https://podmf.com/docs/partner-api-quickstart#verify). Answer any 2xx within 10 seconds; anything else is retried after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h and 24 h (9 tries, about 3 days). After that the delivery has failed: we email you and it shows on the Developers page, where you (or `POST /events/{id}/resend`) can send it again. An endpoint whose deliveries fail for good 5 times in a row is turned off (we email you). Delivery is at least once: dedupe on the event `id`. Order isn't guaranteed: use `order_status` and the times in `data`. Test-mode events (test orders) go only to test endpoints; product events are sent in both modes. Events are kept 30 days (`GET /events` to backfill). **Orders.** A live order is charged at once (wholesale + shipping + tax) to the card you saved on the Developers page, then goes to production. A declined card leaves the order `payment_failed`; we email your billing contact and retry automatically after 1, 6 and 24 hours, then hold it (`on_hold`, `payment_failed_final`): fix the card and call `retry_payment`. Nothing unpaid is ever made. A test-key order is never charged and never made: its payment is simulated (`status: paid`, `charge.status: simulated`); the `simulate` endpoints (Sandbox) then ship it, fail its payment or cancel it, with the same webhooks a live order gets. Send `Idempotency-Key` on every POST: a retry with the same key and body returns the first answer (`Idempotent-Replayed: true`) and never makes a second order or charge. `external_order_id` is unique per brand and mode (`409 duplicate_external_order_id`). US addresses only (`422 country_not_supported`); an address we can't ship to is `422 address_invalid`; one a person should check (no house number, territories, APO/FPO) is accepted and held (`on_hold`, `address_review`); a blank out of stock at the supplier holds the order (`blank_out_of_stock`). Sales tax is not charged in v1 (`tax: 0`). **Keys.** Make keys on the Developers page in POD Mfg. Send one as `Authorization: Bearer podmfg_test_…` (test mode: orders are never charged or produced) or `podmfg_live_…`. A key is shown once; we keep only a hash. Keys made for the Catalog API (`podk_…`) keep working here with the `products:read` scope. **Conventions.** JSON only. Times are ISO 8601 UTC. Money is integer cents with a `currency` (`USD`). Ids are prefixed strings (`prd_`, `var_`, `ord_`, `shp_`, `evt_`, `whk_`, `mer_`, `con_`). Lists are cursor-paginated: `?limit=50&starting_after=` → `{ data, has_more, next_cursor }`. Every response carries `X-Request-Id`; include it when you contact us. Rate limit: 10 requests a second per key, bursts of 50 (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After` on 429). Within `v1`, changes are additive only: ignore fields you don't know. contact: name: POD Mfg url: https://podmf.com/docs/partner-api servers: - url: https://app.podmf.com/api/v1 description: Production (test and live keys both use it; the key decides the mode) security: - bearerAuth: [] tags: - name: Account - name: Products - name: Orders - name: Webhooks - name: Sandbox description: Test-mode simulations, so you can build your webhook handler end to end before going live. paths: /me: get: operationId: getMe tags: [Account] summary: Who this key is (a connection test) description: Your brand, the key's mode and scopes, and whether a card is on file. responses: "200": description: The merchant behind the key. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } content: application/json: schema: { $ref: "#/components/schemas/Merchant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /products: get: operationId: listProducts tags: [Products] summary: List published products description: | Your published products, newest first, each with its variants. Needs `products:read`. Unknown query parameters are refused (`400 parameter_unknown`). parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/StartingAfter" - name: updated_since in: query description: Only products changed at or after this time (ISO 8601). schema: { type: string, format: date-time } - name: status in: query description: Only `published` is available. schema: { type: string, enum: [published] } responses: "200": description: A page of products. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } content: application/json: schema: { $ref: "#/components/schemas/ProductList" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /products/{id}: get: operationId: getProduct tags: [Products] summary: One published product description: The product with its variants, mockup images and prices. Needs `products:read`. parameters: - name: id in: path required: true schema: { type: string, pattern: "^prd_[0-9A-Za-z]{22}$" } responses: "200": description: The product. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Product" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /variants/{id}: parameters: - name: id in: path required: true schema: { type: string, pattern: "^var_[0-9A-Za-z]{22}$" } get: operationId: getVariant tags: [Products] summary: One variant description: A variant of a published product. Needs `products:read`. responses: "200": description: The variant. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Variant" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } patch: operationId: updateVariant tags: [Products] summary: Set your own SKU for a variant description: | The only change allowed on products in v1. `external_sku` is unique among your variants (`409 external_sku_taken`); `null` clears it. Orders may name a variant by its `id`, our `sku` or your `external_sku`. Needs `orders:write`. requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [external_sku] properties: external_sku: type: [string, "null"] minLength: 1 maxLength: 100 description: Printable ASCII; surrounding spaces are trimmed. example: { external_sku: TEAM-TEE-NAVY-M } responses: "200": description: The updated variant. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Variant" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders: post: operationId: createOrder tags: [Orders] summary: Create an order description: | Needs `orders:write`. Each line names exactly one of `variant_id`, `sku` (ours) or `external_sku` (yours); unknown fields are refused. Answers `201` with the order, its `status` and the `charge` result: a live order is charged now (`charge.status` `succeeded` → `status: paid`; `failed` → `status: payment_failed`, retried automatically; `pending` → Stripe is still working on it). A held order (`on_hold`) isn't charged until we release it. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/OrderCreate" } example: external_order_id: "1042" external_order_url: https://merchant-admin.example.com/orders/1042 shipping_method: standard ship_to: name: Jane Smith company: null address1: 123 Main St address2: Apt 4 city: Irvine state: CA postal_code: "92618" country: US phone: "+19495551234" email: jane@example.com line_items: - { variant_id: var_31xaQ8dZr0mJxWb7fKp2sT, quantity: 2, retail_price: 2800, external_line_id: li_1 } packing_slip: { show_prices: false, message: Thanks for supporting the team! } metadata: { anything: merchant wants echoed back } responses: "201": description: The order. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Unprocessable" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } "503": { $ref: "#/components/responses/Unavailable" } get: operationId: listOrders tags: [Orders] summary: List orders description: Your orders in the key's mode (a test key sees test orders only), newest first. Needs `orders:read`. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/StartingAfter" - name: status in: query schema: { $ref: "#/components/schemas/OrderStatus" } - name: external_order_id in: query schema: { type: string, maxLength: 100 } - name: created_since in: query description: Only orders created at or after this time (ISO 8601). schema: { type: string, format: date-time } responses: "200": description: A page of orders. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" } X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" } content: application/json: schema: { $ref: "#/components/schemas/OrderList" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders/estimate: post: operationId: estimateOrder tags: [Orders] summary: Price an order without making it description: | The same body as `POST /orders` (`external_order_id` optional). Answers the wholesale total, shipping and tax, and whether the order would be held. Nothing is stored or charged. Needs `orders:write` or `orders:read`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/OrderEstimateRequest" } example: shipping_method: standard ship_to: { name: Jane Smith, address1: 123 Main St, city: Irvine, state: CA, postal_code: "92618", country: US } line_items: - { sku: PMG-ST350-NAV-M-8F2KA19C0D, quantity: 12 } responses: "200": description: The estimate. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/OrderEstimate" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/Unprocessable" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } "503": { $ref: "#/components/responses/Unavailable" } /orders/{id}: get: operationId: getOrder tags: [Orders] summary: One order description: Status, the price snapshot, charges, refunds and shipments. Needs `orders:read`. parameters: - $ref: "#/components/parameters/OrderId" responses: "200": description: The order. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders/{id}/cancel: post: operationId: cancelOrder tags: [Orders] summary: Cancel an order before production description: | Allowed until the order is `in_production`; a paid order is refunded in full. Cancelling a cancelled order answers it again. After production starts: `409 order_not_cancellable` (contact us). Needs `orders:write`. parameters: - $ref: "#/components/parameters/OrderId" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: false content: application/json: schema: type: object additionalProperties: false properties: reason: { type: string, minLength: 1, maxLength: 200 } responses: "200": description: The cancelled order (with its refund). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders/{id}/retry_payment: post: operationId: retryOrderPayment tags: [Orders] summary: Charge a failed order again description: | For a live order that is `payment_failed`, or `on_hold` with `payment_failed_final` (e.g. after you saved a new card). Answers the paid order, or `402 payment_failed` when the card is declined again. Test orders: a simulated failure (`POST /orders/{id}/simulate/payment_failed`) retries into simulated success (`order.paid`); any other test order is `409 test_mode`. Needs `orders:write`. parameters: - $ref: "#/components/parameters/OrderId" - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: The order after the charge. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "402": { $ref: "#/components/responses/PaymentFailed" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders/{id}/simulate/ship: post: operationId: simulateOrderShipped tags: [Sandbox] summary: "Test mode: mark a test order shipped" description: | Makes one simulated parcel for the whole order (carrier USPS, service Ground Advantage, a fake tracking number `TEST` + 16 digits) and moves the order to `shipped`. An `order.shipped` webhook goes to your **test** endpoints through the same pipeline (signing, retries, delivery log) as a real shipment. Nothing is made or shipped. Needs the order `paid`. **Test keys and test orders only** (a live key: `403 test_mode_only`; a live order's id is not found with a test key). No body. Needs `orders:write`. Wrong state: `409 order_state`. parameters: - $ref: "#/components/parameters/OrderId" - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: The test order after the simulation. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders/{id}/simulate/payment_failed: post: operationId: simulateOrderPaymentFailed tags: [Sandbox] summary: "Test mode: fail a test order's payment" description: | The simulated payment fails: the order becomes `payment_failed` (`status_reason: card_declined`, `charge.status: failed`) and `order.payment_failed` is sent. Then `POST /orders/{id}/retry_payment` simulates success (`order.paid`), or cancel it. No card is involved. Needs the order `paid`. **Test keys and test orders only** (a live key: `403 test_mode_only`; a live order's id is not found with a test key). No body. Needs `orders:write`. Wrong state: `409 order_state`. parameters: - $ref: "#/components/parameters/OrderId" - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: The test order after the simulation. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /orders/{id}/simulate/cancel: post: operationId: simulateOrderCancelled tags: [Sandbox] summary: "Test mode: POD Mfg cancels a test order" description: | Cancels the order as POD Mfg staff would (`reason: cancelled_by_pod_mfg`) and sends `order.cancelled`, so you can test an order you did not cancel yourself. Allowed before production, like `POST /orders/{id}/cancel`. **Test keys and test orders only** (a live key: `403 test_mode_only`; a live order's id is not found with a test key). No body. Needs `orders:write`. Wrong state: `409 order_state`. parameters: - $ref: "#/components/parameters/OrderId" - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: The test order after the simulation. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /webhook_endpoints: post: operationId: createWebhookEndpoint tags: [Webhooks] summary: Add a webhook endpoint description: | In the key's mode (a test key makes a test endpoint). Live endpoints must be https; the host must be a public address. Answers the endpoint with its signing `secret`, the only time it is shown (a replay with the same Idempotency-Key answers `secret: null`). At most 16 endpoints per mode. Needs `orders:write`. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpointCreate" } example: url: https://merchant.example.com/podmfg/webhooks enabled_events: [order.shipped, order.payment_failed, order.cancelled] description: Order system responses: "201": description: The endpoint, with its secret. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" } content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Unprocessable" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } "503": { $ref: "#/components/responses/Unavailable" } get: operationId: listWebhookEndpoints tags: [Webhooks] summary: List webhook endpoints description: Your endpoints in the key's mode, newest first. Never the secrets. Needs `orders:write`. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/StartingAfter" responses: "200": description: A page of endpoints. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpointList" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /webhook_endpoints/{id}: parameters: - $ref: "#/components/parameters/WebhookEndpointId" get: operationId: getWebhookEndpoint tags: [Webhooks] summary: One webhook endpoint description: Needs `orders:write`. responses: "200": description: The endpoint. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpoint" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } patch: operationId: updateWebhookEndpoint tags: [Webhooks] summary: Change a webhook endpoint description: | Any of `url`, `enabled_events`, `description` (null clears it), `status` (`active` / `disabled`). Turning an endpoint back on forgets its failures and resumes deliveries that were waiting. Needs `orders:write`. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpointUpdate" } example: enabled_events: ["*"] responses: "200": description: The endpoint. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpoint" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Unprocessable" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } delete: operationId: deleteWebhookEndpoint tags: [Webhooks] summary: Delete a webhook endpoint description: Nothing more is sent to it; deliveries still waiting end as failed. Needs `orders:write`. responses: "200": description: Deleted. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: type: object required: [id, object, deleted] properties: id: { type: string, pattern: "^whk_[0-9A-Za-z]{22}$" } object: { type: string, const: webhook_endpoint } deleted: { type: boolean, const: true } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /webhook_endpoints/{id}/rotate_secret: post: operationId: rotateWebhookSecret tags: [Webhooks] summary: Rotate an endpoint's signing secret description: | A new secret, shown once in this answer; deliveries are signed with it from now on (the old one stops at once; update your handler first, or accept both while you switch). Needs `orders:write`. parameters: - $ref: "#/components/parameters/WebhookEndpointId" - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: The endpoint, with its new secret. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } "503": { $ref: "#/components/responses/Unavailable" } /events: get: operationId: listEvents tags: [Webhooks] summary: List events description: | The events of the key's mode, newest first, exactly as they were (or would have been) sent: to poll, or to backfill after your endpoint was down. Kept 30 days. Needs `orders:read`. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/StartingAfter" - name: type in: query schema: { $ref: "#/components/schemas/EventType" } - name: since in: query description: Only events created at or after this time (ISO 8601). schema: { type: string, format: date-time } responses: "200": description: A page of events. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/EventList" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /events/{id}: get: operationId: getEvent tags: [Webhooks] summary: One event description: The envelope exactly as sent. Needs `orders:read`. parameters: - $ref: "#/components/parameters/EventId" responses: "200": description: The event. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Event" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /events/{id}/resend: post: operationId: resendEvent tags: [Webhooks] summary: Send an event again description: | To one endpoint (`webhook_endpoint_id`) or to every active endpoint of the key's mode that wants the event's type. Each delivery starts the retry schedule again; it goes out within seconds. `409 no_webhook_endpoint` when no active endpoint wants it. Needs `orders:write`. parameters: - $ref: "#/components/parameters/EventId" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: false content: application/json: schema: type: object additionalProperties: false properties: webhook_endpoint_id: { type: string, pattern: "^whk_[0-9A-Za-z]{22}$" } responses: "200": description: Queued. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: type: object required: [object, event_id, webhook_endpoints] properties: object: { type: string, const: event_resend } event_id: { type: string, pattern: "^evt_[0-9A-Za-z]{22}$" } webhook_endpoints: type: array items: { type: string, pattern: "^whk_[0-9A-Za-z]{22}$" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "409": { $ref: "#/components/responses/Conflict" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } webhooks: order.shipped: post: operationId: webhookOrderShipped summary: A shipment was recorded (one per shipment) parameters: - { name: PODMFG-Signature, in: header, required: true, schema: { type: string, examples: ["t=1791400925,v1=5257a869…"] } } - { name: PODMFG-Event-Id, in: header, required: true, schema: { type: string } } - { name: PODMFG-Event-Type, in: header, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: allOf: - $ref: "#/components/schemas/Event" - type: object properties: data: { $ref: "#/components/schemas/OrderShippedData" } responses: "200": { description: Any 2xx within 10 seconds is a success. } order.events: post: operationId: webhookOrderEvent summary: "order.created, order.paid, order.payment_failed, order.on_hold, order.in_production, order.delivered, order.cancelled" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/Event" } responses: "200": { description: Any 2xx within 10 seconds is a success. } product.events: post: operationId: webhookProductEvent summary: "product.published, product.updated, product.unpublished (sent in both modes)" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/Event" } responses: "200": { description: Any 2xx within 10 seconds is a success. } components: securitySchemes: bearerAuth: type: http scheme: bearer description: "`podmfg_test_…` / `podmfg_live_…` (or a Catalog API `podk_…` key, products:read only)." headers: RequestId: description: This request's id (`req_…`). Also in every error body. schema: { type: string, pattern: "^req_[0-9A-Za-z]+$" } RateLimitLimit: description: The burst size for this key. schema: { type: integer } RateLimitRemaining: description: Requests left before the limit (refills at 10 a second). schema: { type: integer } RetryAfter: description: Seconds to wait before retrying. schema: { type: integer } IdempotentReplayed: description: "`true` when this answer is the stored first answer of an Idempotency-Key." schema: { type: string, const: "true" } parameters: Limit: name: limit in: query description: Page size, 1 to 100. schema: { type: integer, minimum: 1, maximum: 100, default: 50 } StartingAfter: name: starting_after in: query description: The `next_cursor` of the previous page (the last id you got). schema: { type: string } OrderId: name: id in: path required: true schema: { type: string, pattern: "^ord_[0-9A-Za-z]{22}$" } WebhookEndpointId: name: id in: path required: true schema: { type: string, pattern: "^whk_[0-9A-Za-z]{22}$" } EventId: name: id in: path required: true schema: { type: string, pattern: "^evt_[0-9A-Za-z]{22}$" } IdempotencyKey: name: Idempotency-Key in: header description: | Any unique string (a UUID works), 1 to 255 printable characters. Kept 24 hours per brand and mode. Same key + same body → the first answer again; same key + another body → `409 idempotency_key_reused`; while the first is still running → `409 idempotency_key_in_use`. A request that failed frees its key. schema: { type: string, minLength: 1, maxLength: 255 } responses: BadRequest: description: The request is malformed (`parameter_invalid`, `parameter_unknown`, `parameter_missing`, `body_invalid`, `line_item_reference`, `idempotency_key_invalid`, `resource_missing` cursor). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } Unauthorized: description: No key, or not a valid one (`api_key_missing`, `api_key_invalid`). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } Forbidden: description: The key lacks a scope (`missing_scope`), the brand hasn't accepted the Terms of Service (`terms_required`), or a simulation was asked with a live key (`test_mode_only`). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } NotFound: description: No such resource for your brand (`resource_missing`). Another brand's ids are always 404. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } Conflict: description: The change clashes with existing data or the order's state (`external_sku_taken`, `duplicate_external_order_id`, `idempotency_key_reused`, `idempotency_key_in_use`, `order_not_cancellable`, `charge_in_progress`, `order_not_payable`, `test_mode`, `order_state`, `too_many_webhook_endpoints`, `no_webhook_endpoint`). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } Unprocessable: description: Well-formed but can't be done (`address_invalid`, `country_not_supported`, `variant_not_found`, `sku_not_found`, `external_sku_not_found`, `variant_not_available`, `price_unavailable`, `shipping_method_unavailable`, `personalization_unsupported`, `amount_too_small`, `url_not_allowed`). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } PaymentFailed: description: The card was declined again (`payment_error`, `payment_failed`). The order's `charge` says why. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } Unavailable: description: Payments, shipping quotes or webhook set-up are offline for a moment (`payments_unavailable`, `shipping_unavailable`, `webhooks_unavailable`). Retry with the same Idempotency-Key. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } RateLimited: description: Over 10 requests a second (bursts of 50) for this key, or too many bad keys from your address (`rate_limited`). headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } Retry-After: { $ref: "#/components/headers/RetryAfter" } content: application/json: schema: { $ref: "#/components/schemas/Error" } ServerError: description: Our fault (`internal_error`). Safe to retry reads. headers: X-Request-Id: { $ref: "#/components/headers/RequestId" } content: application/json: schema: { $ref: "#/components/schemas/Error" } schemas: Error: type: object additionalProperties: false required: [error] properties: error: type: object additionalProperties: false required: [type, code, message, param, request_id] properties: type: type: string enum: [authentication_error, permission_error, invalid_request_error, payment_error, rate_limit_error, api_error] code: { type: string, examples: [variant_not_found] } message: { type: string } param: { type: [string, "null"], examples: ["line_items[0].variant_id"] } request_id: { type: string, examples: [req_abc123] } Merchant: type: object required: [object, id, name, livemode, api_version, key, billing, connection] properties: object: { type: string, const: merchant } id: { type: string, pattern: "^mer_[0-9A-Za-z]{22}$" } name: { type: string } livemode: { type: boolean, description: "false for a test key: orders are never charged or produced." } api_version: { type: string, const: v1 } key: type: object required: [kind, scopes, livemode] properties: kind: { type: string, enum: [partner, catalog] } scopes: type: array items: { type: string, enum: ["products:read", "orders:write", "orders:read"] } livemode: { type: boolean } billing: type: object required: [card_on_file, billing_mode] properties: card_on_file: { type: boolean } billing_mode: { type: string, enum: [per_order, prepaid, internal] } connection: description: The brand's API connection ("Connect via API"), or null. oneOf: - type: "null" - type: object required: [id, type, status] properties: id: { type: string, pattern: "^con_[0-9A-Za-z]{22}$" } type: { type: string, const: api } status: { type: string, enum: [pending, active, error, disconnected] } ProductList: type: object required: [object, data, has_more, next_cursor] properties: object: { type: string, const: list } data: type: array items: { $ref: "#/components/schemas/Product" } has_more: { type: boolean } next_cursor: { type: [string, "null"], description: "Pass as `starting_after` for the next page." } Product: type: object required: [id, object, status, title, description, blank, decoration_method, images, variants, published_at, created_at, updated_at] properties: id: { type: string, pattern: "^prd_[0-9A-Za-z]{22}$" } object: { type: string, const: product } status: { type: string, enum: [published] } title: { type: string } description: { type: string } blank: type: object required: [brand, style, name] properties: brand: { type: [string, "null"], examples: [Sport-Tek] } style: { type: [string, "null"], examples: [ST350] } name: { type: string, examples: [PosiCharge Competitor Tee] } decoration_method: type: [string, "null"] enum: [dtg, dtf, sublimation, embroidery, finished, null] images: type: array description: Mockups. Links are signed and work for 24 hours; fetch the product again for fresh ones. items: type: object required: [url, position, color, is_primary] properties: url: { type: string, format: uri } position: { type: string, enum: [front, back] } color: { type: [string, "null"] } is_primary: { type: boolean } variants: type: array items: { $ref: "#/components/schemas/Variant" } published_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } Variant: type: object required: [id, object, product_id, sku, external_sku, options, retail_price, wholesale_price, currency, weight_grams, in_stock] properties: id: { type: string, pattern: "^var_[0-9A-Za-z]{22}$" } object: { type: string, const: variant } product_id: { type: string, pattern: "^prd_[0-9A-Za-z]{22}$" } sku: { type: string, description: "Ours, stable.", examples: [PMG-ST350-NAV-M-8F2KA19C0D] } external_sku: { type: [string, "null"], description: "Yours (PATCH /variants/{id})." } options: type: object required: [color, size] properties: color: { type: [string, "null"], examples: [Navy] } size: { type: string, examples: [M] } retail_price: { type: integer, description: "Your retail price, cents." } wholesale_price: type: [integer, "null"] description: What you pay us for one unit, cents (before shipping and tax). null while its decoration price isn't set up. currency: { type: string, const: USD } weight_grams: { type: [integer, "null"] } in_stock: { type: boolean, description: "The blank is available at the supplier (true when the supplier reports no stock number)." } OrderStatus: type: string description: | received → payment_pending → paid → in_production → partially_shipped → shipped → delivered; payment_failed (retrying), on_hold (status_reason: address_review, blank_out_of_stock, payment_failed_final, artwork_issue, manual_review), cancelled, refunded (money returned after shipping). enum: [received, payment_pending, paid, in_production, partially_shipped, shipped, delivered, payment_failed, cancelled, on_hold, refunded] Address: type: object additionalProperties: false required: [name, address1, city, state, postal_code, country] properties: name: { type: string, maxLength: 120 } company: { type: [string, "null"], maxLength: 120 } address1: { type: string, maxLength: 200 } address2: { type: [string, "null"], maxLength: 200 } city: { type: string, maxLength: 100 } state: { type: string, description: "2-letter code.", examples: [CA] } postal_code: { type: string, examples: ["92618"] } country: { type: string, const: US } phone: { type: [string, "null"], maxLength: 40 } email: { type: [string, "null"], maxLength: 254 } LineItemCreate: type: object additionalProperties: false required: [quantity] description: Exactly one of variant_id, sku, external_sku. properties: variant_id: { type: string, pattern: "^var_[0-9A-Za-z]{22}$" } sku: { type: string, maxLength: 100 } external_sku: { type: string, maxLength: 100 } quantity: { type: integer, minimum: 1, maximum: 10000 } retail_price: { type: [integer, "null"], minimum: 0, maximum: 1000000, description: "What your customer paid per unit, cents (packing slip / customs). Default: the product's retail price." } external_line_id: { type: [string, "null"], maxLength: 100 } OrderCreate: type: object additionalProperties: false required: [external_order_id, ship_to, line_items] properties: external_order_id: { type: string, minLength: 1, maxLength: 100 } external_order_url: { type: [string, "null"], format: uri, maxLength: 500 } shipping_method: type: [string, "null"] description: "`standard` (default: the cheapest), `expedited` (the fastest), or one of our service keys (e.g. `ups_ground`)." ship_to: { $ref: "#/components/schemas/Address" } line_items: type: array minItems: 1 maxItems: 250 items: { $ref: "#/components/schemas/LineItemCreate" } packing_slip: type: [object, "null"] additionalProperties: false properties: show_prices: { type: boolean } message: { type: [string, "null"], maxLength: 300 } metadata: type: [object, "null"] description: Echoed back; at most 4 KB. OrderEstimateRequest: description: The OrderCreate body with `external_order_id` optional. type: object additionalProperties: true OrderEstimate: type: object required: [object, livemode, currency, line_items, shipping, subtotal, shipping_total, tax, total, would_hold] properties: object: { type: string, const: order_estimate } livemode: { type: boolean } currency: { type: string, const: USD } line_items: type: array items: type: object required: [variant_id, sku, quantity, wholesale_price, retail_price, total, in_stock] properties: variant_id: { type: string } sku: { type: string } external_sku: { type: [string, "null"] } external_line_id: { type: [string, "null"] } quantity: { type: integer } wholesale_price: { type: integer, description: "Per unit at this order's quantity." } retail_price: { type: integer } total: { type: integer } in_stock: { type: boolean } shipping: { $ref: "#/components/schemas/Shipping" } subtotal: { type: integer } shipping_total: { type: integer } tax: { type: integer } total: { type: integer } would_hold: type: [object, "null"] properties: reason: { type: string } detail: { type: string } Shipping: type: object properties: service: { type: string, examples: [ups_ground] } label: { type: string } carrier: { type: string } amount: { type: integer } days_min: { type: [integer, "null"] } days_max: { type: [integer, "null"] } Charge: type: object required: [status, amount, currency, livemode] properties: status: type: string enum: [succeeded, failed, pending, not_attempted, simulated, not_charged] description: "`simulated` / `not_charged` are test mode (never a real charge); a test order's `failed` is a simulated decline (`simulate/payment_failed`)." amount: { type: integer } currency: { type: string, const: USD } livemode: { type: boolean } attempt: { type: integer } failure_code: { type: [string, "null"], examples: [card_declined, authentication_required, no_card] } failure_message: { type: [string, "null"] } requires_action: { type: boolean, description: "Your bank wants you to confirm the card: save it again on the Developers page, then retry." } next_retry_at: { type: [string, "null"], format: date-time } Order: type: object required: [id, object, livemode, number, status, status_reason, external_order_id, line_items, currency, subtotal, shipping_total, tax, total, charge, charges, refunds, shipments, created_at] properties: id: { type: string, pattern: "^ord_[0-9A-Za-z]{22}$" } object: { type: string, const: order } livemode: { type: boolean } number: { type: integer, description: "Our order number (what our team calls it)." } status: { $ref: "#/components/schemas/OrderStatus" } status_reason: { type: [string, "null"] } external_order_id: { type: string } external_order_url: { type: [string, "null"] } shipping_method: { type: [string, "null"], description: "What you asked for." } shipping: oneOf: - type: "null" - $ref: "#/components/schemas/Shipping" ship_to: { type: object } line_items: type: array items: type: object properties: variant_id: { type: string } sku: { type: string } external_sku: { type: [string, "null"] } external_line_id: { type: [string, "null"] } name: { type: string } quantity: { type: integer } wholesale_price: { type: integer, description: "What you pay per unit (snapshot; never recomputed)." } retail_price: { type: integer } tax: { type: integer } total: { type: integer } packing_slip: { type: object } metadata: { type: object } currency: { type: string, const: USD } subtotal: { type: integer } shipping_total: { type: integer } tax: { type: integer } total: { type: integer } charge: { $ref: "#/components/schemas/Charge" } charges: type: array items: { $ref: "#/components/schemas/Charge" } refunds: type: array items: type: object properties: amount: { type: integer } currency: { type: string } status: { type: string } created_at: { type: string, format: date-time } shipments: type: array items: type: object properties: id: { type: string, pattern: "^shp_[0-9A-Za-z]{22}$" } carrier: { type: string } tracking_number: { type: string } tracking_url: { type: [string, "null"] } shipped_at: { type: string, format: date-time } line_items: { type: array, items: { type: object } } paid_at: { type: [string, "null"], format: date-time } cancelled_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } EventType: type: string enum: [product.published, product.updated, product.unpublished, order.created, order.paid, order.payment_failed, order.on_hold, order.in_production, order.shipped, order.delivered, order.cancelled, ping] description: "`ping` is the Developers page's test event; `order.delivered` needs a carrier tracking feed (none yet, so it is never sent today)." WebhookEndpoint: type: object required: [id, object, livemode, url, description, enabled_events, status, disabled_reason, secret_hint, created_at, updated_at] properties: id: { type: string, pattern: "^whk_[0-9A-Za-z]{22}$" } object: { type: string, const: webhook_endpoint } livemode: { type: boolean } url: { type: string } description: { type: [string, "null"] } enabled_events: type: array items: { type: string, description: "An event type (not ping), or * for all." } status: { type: string, enum: [active, disabled] } disabled_reason: type: [string, "null"] enum: [merchant, failing, staff, null] description: "`failing` = turned off after 5 deliveries in a row failed every retry." disabled_at: { type: [string, "null"], format: date-time } secret_hint: { type: string, examples: ["whsec_…a1B2"] } secret_rotated_at: { type: [string, "null"], format: date-time } failing_since: { type: [string, "null"], format: date-time } last_success_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } WebhookEndpointWithSecret: allOf: - $ref: "#/components/schemas/WebhookEndpoint" - type: object required: [secret] properties: secret: type: [string, "null"] pattern: "^whsec_[0-9A-Za-z]{32}$" description: The signing secret, shown only in this answer (null in an Idempotency-Key replay). WebhookEndpointCreate: type: object additionalProperties: false required: [url, enabled_events] properties: url: { type: string, maxLength: 2048, examples: ["https://merchant.example.com/podmfg/webhooks"] } enabled_events: type: array minItems: 1 items: { type: string } examples: [["order.shipped", "order.payment_failed"], ["*"]] description: { type: [string, "null"], maxLength: 200 } WebhookEndpointUpdate: type: object additionalProperties: false properties: url: { type: string, maxLength: 2048 } enabled_events: { type: array, minItems: 1, items: { type: string } } description: { type: [string, "null"], maxLength: 200 } status: { type: string, enum: [active, disabled] } WebhookEndpointList: type: object required: [object, data, has_more, next_cursor] properties: object: { type: string, const: list } data: type: array items: { $ref: "#/components/schemas/WebhookEndpoint" } has_more: { type: boolean } next_cursor: { type: [string, "null"] } Event: type: object additionalProperties: false required: [id, type, created_at, livemode, api_version, data] description: | The envelope of every webhook. `data` by type. `order.shipped`: `{ order_id, external_order_id, shipment: { id, carrier, service, tracking_number, tracking_url, shipped_at, line_items: [{ external_line_id, variant_id, quantity }] }, order_status }` (one event per shipment: a partial shipment is its own event). Other `order.*`: `{ order_id, external_order_id, order_status, previous_status, reason, occurred_at, order }` (`order` = the Order object when the event was made); `order.payment_failed` adds `failure_code`, `failure_message`, `next_retry_at`; `order.cancelled` adds `refunded_amount`, `currency`. `product.*`: `{ product_id, product_status, product }` (`product` = the Product object; null once unpublished). `ping`: `{ webhook_endpoint_id, message }`. properties: id: { type: string, pattern: "^evt_[0-9A-Za-z]{22}$" } type: { $ref: "#/components/schemas/EventType" } created_at: { type: string, format: date-time } livemode: { type: boolean } api_version: { type: string, const: v1 } data: { type: object } EventList: type: object required: [object, data, has_more, next_cursor] properties: object: { type: string, const: list } data: type: array items: { $ref: "#/components/schemas/Event" } has_more: { type: boolean } next_cursor: { type: [string, "null"] } OrderShippedData: type: object additionalProperties: false required: [order_id, external_order_id, shipment, order_status] properties: order_id: { type: string, pattern: "^ord_[0-9A-Za-z]{22}$" } external_order_id: { type: string } order_status: { $ref: "#/components/schemas/OrderStatus" } shipment: type: object additionalProperties: false required: [id, carrier, service, tracking_number, tracking_url, shipped_at, line_items] properties: id: { type: string, pattern: "^shp_[0-9A-Za-z]{22}$" } carrier: { type: string, examples: [USPS] } service: { type: [string, "null"], examples: ["UPS Ground"] } tracking_number: { type: string } tracking_url: { type: [string, "null"] } shipped_at: { type: string, format: date-time } line_items: type: array items: type: object properties: external_line_id: { type: [string, "null"] } variant_id: { type: string, pattern: "^var_[0-9A-Za-z]{22}$" } quantity: { type: integer } OrderList: type: object required: [object, data, has_more, next_cursor] properties: object: { type: string, const: list } data: type: array items: { $ref: "#/components/schemas/Order" } has_more: { type: boolean } next_cursor: { type: [string, "null"] }