Medusa
Protection on Medusa.
A line item your backend adds and an order payload it pushes. More control than the Shopify path, and one extra responsibility: your push carries no API key, so it has to say what the Shopify one implies.
How it fits together
Medusa has no Covr app to install. Protection is a line item your backend adds to the cart, and an order payload your backend pushes to us once the order is placed.
That gives you more control than the Shopify path and one extra responsibility: your push carries no API key, so it authenticates with a shared secret that says nothing about whether an order is real. Everything on this page follows from that.
| Field | Type | Description |
|---|---|---|
| 1. Quote | server | Call POST /v2/quotes from your backend with the cart's insurable value. Keep the returned quote id — it is what proves later what the shopper was actually shown. |
| 2. The protection line | cart | Add one line item priced at the quoted premium. It has no product, no variant, and no shipping profile. Read the fulfilment section below before you ship anything. |
| 3. Push the order | server | POST the completed order to your webhook endpoint. This is what books the premium. |
Fulfilment: you cannot fulfil everything at once
Selecting every line on a protected order and pressing Fulfil returns an error. This is Medusa behaving as designed, not a misconfiguration, and every Medusa merchant meets it on their first protected order.
createOrderFulfillmentWorkflow calls throwIfItemsAreNotGroupedByShippingRequirement, which partitions the requested items and throws if both buckets are non-empty:
Fulfillment can only be created entirely with items with shipping
or items without shipping. Split this request into 2 fulfillments.The protection line is requires_shipping: false — it has nothing to put in a box — and your merchandise is true. Medusa refuses to mix them in one fulfilment.
What to do
Fulfil your physical items and leave the protection line alone. That is the whole instruction. The order ships, the tracking number reaches Covr through your fulfilment webhook, and the protection line simply stays unfulfilled.
Fulfilling it separately also works if your process wants every line closed — the shipping-profile check is skipped for a non-shippable item — but it writes a real fulfilment record and may notify the customer about a shipment that does not exist. Decide that deliberately rather than to tidy a screen.
The error text does not name the item that caused it, which is why this section exists. If you see it on an order you know is fine, the protection line is why.
Four fields that decide what we book
Each of these has a default that is right for most stores and silently wrong for some. None of them raises an error when it is wrong — the order books, the premium looks reasonable, and something downstream disagrees.
| Field | Type | Description |
|---|---|---|
| quote_id | strongly recommended | At the ORDER level, not on the protection line's metadata. It is the only input we do not have to take your word for: the premium comes off our own quote row, and the cart-swap check runs only when a quote resolves. Without it we fall back to the figure you send, which is a number your browser could have chosen. |
| protection_cents | fallback | Used when no quote resolves. Correct, but it is your word for the price rather than ours. |
| price_basis | recommended | Whether unit_price_cents includes sales tax. Absent reads as "pre_tax". If your lines are tax-inclusive you must also send tax_cents — US rates exceed our comparison tolerance, so a tax-inclusive sender that does not declare it has every quote silently rejected. |
| mode | important | Send "test" (or test: true) for sandbox orders. Your push carries no API key, so nothing else can tell a sandbox payment from a real one — a Clover sandbox order booked $73.00 as real receivable before this existed, and weekly invoicing would have billed it. |
On deriving the mode
Derive it from the same value that routes the payment, rather than setting a separate flag. A store doing env ?? "sandbox" to pick its provider should read the mode from that expression too — then a typo sends the provider to sandbox and marks the order test, and both are correct, because the money is not real.
A separate flag drifts from the mechanism exactly when drift is most expensive. Watch the casing: a value differing only by a capital letter charged real cards while marking every order test.
Refunds
You collect the premium at checkout and owe it on. When you refund it, you owe us less — but only if you tell us. Send refund.created to the same webhook you already use for orders and fulfilments.
Without it a refunded order keeps a live premium on your invoice. Nothing errors; the figure is simply wrong in our favour, which is the worst kind of wrong to leave running.
{
"merchant": "your-slug",
"event": "refund.created",
"order": { "id": "<the id you sent on order.placed>" },
"refund": {
"id": "<stable refund id>",
"amount_cents": 6360,
"note": "optional",
"created_at": "2026-09-03T16:04:00Z",
"items": [
{ "sku": "MERCH-SKU", "refunded_cents": 6000 },
{ "sku": "ARRIVEINS", "refunded_cents": 360 }
]
}
}Send lines, not just a total
We reverse exactly what was refunded of the protection line. Include it when you refunded it; omit it when you did not. A full refund then reverses the whole premium and behaves like a cancellation, while a merchandise-only refund moves nothing — because a shopper who returned one of three items still holds cover on the two they kept, and still paid for it. A single amount cannot express that difference.
| Field | Type | Description |
|---|---|---|
| id | required | Stable and unique per refund. This is the idempotency key — a redelivery with the same id is a no-op, and a refund without one is refused rather than risk reversing the same premium twice. |
| items[].sku | required | Matched by SKU, never by title. Protection is detected by SKU on the way in and reversed by SKU on the way out, so a line merely named “Shipping Protection” reverses nothing. |
| items[].refunded_cents | required | Positive integer cents refunded for that line. Absent reads as zero. |
| amount_cents | optional | The total the shopper got back, kept for the record. It is not what we reverse — the lines are. |
| created_at | recommended | When the refund happened. The credit is dated to it rather than to when we received the message, so it lands on the invoice for the week the money actually went back. |
Several refunds against one order are fine — reversal is clamped to whatever premium is left, so a refund, restock and refund again cannot credit more than was charged. If we have not ingested the order yet you get a 500 with order_not_yet_ingested: retry it. A 200 there would drop the reversal for good.
Everything else that reads your order lines
A protection line has no inventory, no weight, no cost of goods and no fulfilment. Anything downstream that assumes every line is a real product will break on it, and it will not look like a Covr problem when it does.
| Field | Type | Description |
|---|---|---|
| Medusa's own cart completion | after payment | A product with no shipping profile makes Medusa refuse to complete the cart — at the last step, after the card is charged. Payments were captured with no orders behind them. Check product_shipping_profile before you go live. |
| Inventory and ERP | retries forever | An inventory system returned HTTP 502 trying to book the protection SKU against stock that does not exist, which retried the whole delivery. Exclude the protection SKU from anything that books inventory. |
| Accounting and 3PL | check before launch | Same class. Audit anything that iterates order lines and assumes a product behind each one. |
Why this is first, not a footnote
Both failures above land after the card is charged and look like the merchant's own system misbehaving, so nobody reports them to us. That is the kind of problem that stays invisible at ten merchants and is a pattern at a hundred.