Skip to main content
Payment and delivery are separate in Shoppex. An order can be paid with status: "COMPLETED" while one of its line items is still waiting for delivery. Use asynchronous fulfillment when your system needs more than 15 seconds to generate content. Use manual fulfillment when a merchant needs to prepare an account, link, code, or file after payment. PHYSICAL products have their own lane: the order is marked shipped and then delivered. See Physical products.
Fulfillment is tracked per line item. The invoice payment status does not change when a line item moves between delivery states.

Delivery states

A line item enters AWAITING_FULFILLMENT in any of these cases:
  • its dynamic delivery callback returns a clear pending acknowledgement: {"status":"pending"}
  • it belongs to a DYNAMIC product that has no dynamic_webhook URL configured
  • it belongs to a PHYSICAL product
The second case is the manual fulfillment lane. After payment, the merchant can deliver the line item from the dashboard or through the Developer API. The third case is the physical lane. There is no separate “shipped” delivery state: a physical line stays AWAITING_FULFILLMENT after the order is marked shipped, and moves to DELIVERED when the order is marked delivered. The shipment itself is reported by the ship endpoint and the order:shipped event.

Read fulfillment state

Order read responses expose these fields on every item. The example below shows the relevant response fields:
id is the line item ID used as {item_id} in the fulfillment endpoints. The order path accepts the same order identifier used by the existing order endpoints.

Fulfill a line item

The order must be paid with status: "COMPLETED", and the line item must not already be delivered. Provide at least one of message, codes, links, or files. Dynamic delivery callbacks must include delivery_id with the deliveryId from the original webhook request. Shoppex rejects a missing or stale generation, or a recipient that no longer matches that request, with 422. Manual fulfillment without a dynamic delivery event does not require this field.
cURL
A successful response returns the delivered line item content:
Each files[].attachment_id must refer to an existing attachment owned by the same shop. Upload attachments in the dashboard before calling this endpoint. If any attachment is missing or belongs to another shop, the entire request fails and no partial delivery is stored.

Customer email

notify_customer controls the buyer’s “product ready” email:
  • Omit it, or set it to true, to request the customer email after the content is stored.
  • Set it to false to store the content without sending that email.
The shop’s normal customer-email settings and notification preferences still apply. Shoppex also sends the order:item.delivered event after a successful fulfillment.

Error responses

Both endpoints reject invalid requests with the standard error envelope. The message distinguishes the cause:

Idempotency

Both fulfillment endpoints support the standard Idempotency-Key header. Reuse the same key when retrying the same request after a network interruption. Shoppex scopes the key to the authenticated API key and route and replays the stored response during the 24-hour idempotency window. For example, every retry of the delivery above must keep Idempotency-Key: fulfill-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1.
Rejected responses (4xx) are replayed too: after correcting a rejected request body — for example a wrong attachment_id — send the corrected request with a new Idempotency-Key. Reusing the old key returns either the stored error or a key-conflict error.
Idempotency does not allow a line item to be delivered twice. Once delivered_at is set, another fulfillment attempt is rejected even if it uses a different key.

Retry failed dynamic delivery

This endpoint re-queues a failed dynamic delivery and moves the line item from FAILED to PENDING. It does not take a request body.
cURL
Retrying can cause a double delivery. A timeout or network error does not tell you whether the vendor already finished the delivery. If the vendor already delivered the product before Shoppex received the failure, check the vendor system. Make sure it checks X-Shoppex-Idempotency-Key for duplicates before you retry.
Only a FAILED dynamic line item with a failed delivery event can be retried. If no retryable event exists, fulfill the line item manually instead.

Physical products

A PHYSICAL line item is fulfilled by shipping it, not by sending content. The flow:
  1. The buyer enters a shipping address in hosted checkout. Shoppex adds the flat price of the matching shipping zone, once per order.
  2. After payment, every physical line is AWAITING_FULFILLMENT. Digital lines in the same cart deliver as usual.
  3. The merchant or your system marks the order shipped, with an optional tracking code and link. Shoppex sends order:shipped and, by default, a shipping email to the buyer.
  4. The merchant or your system marks the order delivered. Every open physical line moves to DELIVERED, and Shoppex sends order:item.delivered for each of them.
One shipment always covers every physical line of the order. There are no partial shipments.

Mark an order shipped

Requires the orders.write scope. {id} accepts the order id or uniqid. All body fields are optional.
cURL
A successful response returns the shipment:
Calling /ship again on a shipped order corrects the tracking. shipped_at keeps the time of the first shipment. A field you leave out keeps its stored value; send null to clear it. The buyer gets one shipping email per distinct tracking. Repeating the call with the same tracking sends no second email; a changed tracking sends an update. The email also follows the shop’s buyer-email settings and the buyer’s notification preferences. Set notify_customer: false to skip it.

Mark an order delivered

Requires the orders.write scope. There is no request body.
cURL
Every physical line that is not delivered yet moves to DELIVERED and gets this delivered-items snapshot:
tracking_codes and trackingCodes are present only when the shipment has a tracking code or link. fulfilled_by is API for Developer API calls and MERCHANT for the dashboard. You can deliver an order that was never marked shipped. shipped_at is then set to the delivery time. Delivering sends no email to the buyer.

Which orders can ship

Both endpoints need an order that:
  • has at least one PHYSICAL line item,
  • is paid: COMPLETED, or PARTIAL_REFUND where every open physical line is still owed,
  • has a shipping address.
Because one shipment covers every open physical line, a PARTIAL_REFUND order where one of those lines was refunded cannot ship at all. The request is refused rather than settling a line the order no longer owes.

Error responses

A 409 CONFLICT carries error.subcode with the specific reason, so you can branch on it instead of the message.

Idempotency

Both endpoints support the standard Idempotency-Key header, scoped to your API key, the route, and the order. A retry with the same key within 24 hours replays the stored response, including a stored error. Without a key, a repeated /ship corrects the tracking (see above) and a repeated /deliver returns 409 CONFLICT with subcode: "ALREADY_DELIVERED". No retry can deliver a line twice.

Orders without an address

A physical order is not payable until the buyer has entered an address in a country one of the shop’s zones covers. Paths that would complete such an order without payment refuse it too: POST /dev/v1/orders/{id}/complete and POST /dev/v1/orders/{id}/fulfill answer 422 VALIDATION_ERROR “This order has physical items but no shipping address yet.”
Physical products can be bought through hosted checkout only. Creating an order that contains one through POST /dev/v1/orders, a dashboard invoice, or POS fails with 400 PHYSICAL_PRODUCT_REQUIRES_CHECKOUT. The reseller program refuses them with RESELLER_PHYSICAL_PRODUCT_UNSUPPORTED: physical products cannot be enrolled in the reseller catalog or ordered wholesale.

Buyer-facing shipping state

The public invoice that hosted checkout reads carries a shipping object on physical orders. Because anyone with the checkout link can read it, it holds no name, street, or phone, only address_summary with city and country:
address_summary is null until the buyer saves an address. countries lists every country the shop ships to. The full address is shown only in the dashboard and, to the signed-in buyer, in the customer portal. The buyer-facing errors while saving the address or paying are:

Shipping zones

A shipping zone is a flat shipping price for a list of countries. Manage zones with the products.read and products.write scopes. POST and PUT take the same body:
cURL
countries comes back upper-case and sorted. created_at and updated_at are Unix timestamps. The list endpoint returns the same objects in data, oldest first, with pagination.has_more: false. A country can belong to only one zone per shop. Write errors: Every refusal but the 404 carries error.subcode. All write endpoints support Idempotency-Key. Changing or deleting a zone does not change orders that are already paid. An unpaid order is quoted again with the current zone the next time the buyer changes the address, coupon, tip, or payment method; if no zone covers its country any more, that step fails with SHIPPING_COUNTRY_NOT_SERVED.

Fulfillment webhook events

These are normal merchant webhook events. Subscribe to them in Settings → Webhooks or through the Developer API. They use the normal event-webhook signature, not the product’s dynamic delivery signing secret.

order:item.delivered

Sent after late content is stored through manual or API fulfillment, and once per physical line when an order is marked delivered.
deliveredItems is the committed delivery snapshot returned by the fulfillment request. licenseKeys is included as a convenience when the snapshot contains codes, so consumers do not need to fetch the order after receiving the event. Both fields can contain sensitive buyer delivery content and should not be logged. For a physical line, deliveredItems is the physical snapshot with delivery_type: "PHYSICAL" and the tracking, and there is no licenseKeys field.

order:shipped

Sent when an order’s physical items are marked shipped, from the dashboard or through POST /dev/v1/orders/{id}/ship. A tracking correction on an already shipped order sends it again with firstShipment: false.
The payload does not contain the shipping address.

order:item.delivery_failed

Sent when the single dynamic delivery attempt ends in a terminal failure. Shoppex also marks the line item FAILED and notifies the merchant.
These events report fulfillment state. They do not change or replace payment events such as order:paid. They go to shop-level webhook subscriptions only; a webhook configured on a single product does not receive them.

Dynamic product delivery

Implement the synchronous and asynchronous callback contract.

Webhook events

Verify normal merchant webhook signatures and review event payloads.