Dropship API

Place production orders programmatically from your own store. We make the parts and ship them directly to your customers.

Using Shopify? Start here — no code needed

Install the RMFG app from the Shopify App Store, connect it to your dropship account, and publish your approved parts as products in one click. The app wires up ordering and fulfillment for you — when a customer buys, we make the part, ship it to them, and push tracking back to Shopify automatically. You never touch an API key, SKU, or webhook by hand.

  1. Install the RMFG app and open it in your Shopify admin.
  2. Paste your API key on the app's Settings tab (from your dropship dashboard) and click Test connection.
  3. On the Catalog tab, set a retail price and click Publish for each approved part.

The reference below is for building your own integration (Squarespace, a custom storefront, etc.) — you only need it if you're not using the Shopify app.

Authentication

All endpoints live under /v1 on the RMFG API. Authenticate every request with your API key, sent in the rmfg_key header. Once your dropship account is approved, generate your key — along with managing your account, card on file, and webhooks — from the dropship dashboard.

Example request
curl https://<api-base>/v1/items \
  -H "rmfg_key: <your API key>"

Every error is JSON with a stable machine-readable code:

Error shape
{"error": {"code": "unknown_sku", "message": "No dropship item with sku 'BRKT-99'."}}
StatusCodeWhen
401missing_api_key / invalid_api_keyrmfg_key header is missing or not recognized.
403dropship_account_not_approvedYour account is not approved (requested, suspended, or denied).

GET /v1/items

Lists your dropship items — both approved (orderable) and still under review. Unit prices are locked when we approve an item and are integer cents.

200 response
{
  "items": [
    {
      "id": "6e1f9a2b-7c3d-4e5f-8a9b-0c1d2e3f4a5b",
      "sku": "BRKT-01",
      "name": "Mounting Bracket",
      "status": "approved",
      "unit_price_cents": 1850,
      "kind": "part",
      "part": {
        "id": "aB3dE5fG7hJ9",
        "material": "aluminum",
        "material_type": "5052-H32",
        "thickness": 3.175
      },
      "assembly": null
    },
    {
      "id": "3c2b1a0f-9e8d-4c7b-a6f5-e4d3c2b1a0f9",
      "sku": "FRAME-03",
      "name": "Welded Frame",
      "status": "approved",
      "unit_price_cents": 24900,
      "kind": "assembly",
      "part": null,
      "assembly": { "id": "xY7zW9vU3tR1", "part_count": 4, "has_welds": true, "file_name": "frame.step" }
    },
    {
      "id": "0d9c8b7a-6e5f-4d3c-2b1a-0e9f8d7c6b5a",
      "sku": "PLATE-02",
      "name": "Base Plate",
      "status": "requested",
      "unit_price_cents": null,
      "kind": "part",
      "part": { "id": "kL2mN4pQ6rS8", "material": "steel", "material_type": "cold rolled", "thickness": 1.897 },
      "assembly": null
    }
  ]
}

Items are single parts (kind: "part") or assemblies (kind: "assembly" — multi-part units, optionally welded, reproduced from your sample order). Ordering is identical either way: one SKU, one locked unit price per part or assembly unit.

Only status: "approved" items can be ordered. Request new items from any part on one of your past orders (order detail page → "Request for dropship").

POST /v1/orders

Creates a production order and charges your card on file off-session for the parts (unit_price_cents × quantity). Shipping is charged separately to the same card at fulfillment — after we ship your order, once the real parcel and label cost are known. Only US destinations are supported.

If the shipping charge fails, we still ship your order. You're notified via the order.payment_failed webhook event, and the outstanding balance can be settled from the dropship dashboard — outstanding charges are also retried automatically when you replace your card.

Request
POST /v1/orders
rmfg_key: <your API key>
Content-Type: application/json

{
  "sku": "BRKT-01",
  "quantity": 2,
  "external_ref": "shopify-1001",
  "retail_unit_price_cents": 2999,
  "ship_to": {
    "name": "Jane Doe",
    "street1": "500 Market St",
    "street2": null,
    "city": "San Francisco",
    "state": "CA",
    "zip": "94107",
    "country": "US",
    "phone": "415-555-0100",
    "email": "jane@example.com"
  }
}
201 response
{
  "order": {
    "id": "k3x9mQ2pL7vD",
    "status": "paid",
    "external_ref": "shopify-1001",
    "sku": "BRKT-01",
    "quantity": 2,
    "parts_total_cents": 3700,
    "shipping": { "status": "charged_at_fulfillment" },
    "created_at": "2026-07-05T12:00:00+00:00"
  }
}

Idempotency

Pass your own order id (e.g. the Shopify order number) as external_ref. If we already have an order with that external_ref on your account, we return the existing order with status 200 instead of 201 — no duplicate order, no double charge. Safe to retry on timeouts.

Contact phone (strongly recommended)

Include ship_to.phonewhenever you have it: carriers require a contact number on the shipment; if omitted we use your account's contact phone.

Retail price (optional)

You can optionally tell us what your customer paid, per unit, in retail_unit_price_cents (integer cents). You never have to send it — our store integrations (like Shopify) fill it in automatically from the order.

It never affects your bill: you always pay the locked wholesale price. We only use it to unlock better pricing for you over time — for example, spotting parts you sell often so we can offer volume discounts on the next production run.

Errors

StatusCodeWhen
402card_declinedThe off-session charge was declined. No order is created. The message contains Stripe's decline reason.
402payment_not_completedThe payment could not complete off-session (e.g. the card requires authentication). No order is created.
403item_not_approvedThe SKU exists but is not approved for ordering.
404unknown_skuNo dropship item with that SKU on your account.
409no_payment_methodNo card on file. Add one in the dropship dashboard.
422unsupported_countryship_to.country must be "US".

GET /v1/orders and /v1/orders/{id}

GET /v1/orders lists your API orders, filterable with ?external_ref=shopify-1001. GET /v1/orders/{id} returns one order including tracking (once shipped) and the charge breakdown.

200 response (single order, shipped)
{
  "order": {
    "id": "k3x9mQ2pL7vD",
    "status": "shipped",
    "external_ref": "shopify-1001",
    "sku": "BRKT-01",
    "quantity": 2,
    "parts_total_cents": 3700,
    "shipping": { "status": "charged_at_fulfillment" },
    "tracking": {
      "carrier": "FedEx",
      "number": "781234567890",
      "url": "https://www.fedex.com/fedextrack/?trknbr=781234567890"
    },
    "charges": [
      { "kind": "parts", "amount_cents": 3700, "status": "succeeded", "stripe_payment_intent_id": "pi_...", "created_at": "2026-07-05T12:00:00+00:00" },
      { "kind": "shipping", "amount_cents": 1450, "status": "succeeded", "stripe_payment_intent_id": "pi_...", "created_at": "2026-07-08T16:20:00+00:00" }
    ],
    "created_at": "2026-07-05T12:00:00+00:00"
  }
}

Webhook endpoints

Manage endpoints via the API or the dashboard. Available events: order.created, order.in_review, order.in_production, order.shipped, order.delivered, order.cancelled, order.payment_failed.

  • GET /v1/webhook-endpoints — list active endpoints (secrets are never returned here)
  • POST /v1/webhook-endpoints with {"url": "...", "events": ["order.shipped"]} (events optional — defaults to all events)
  • DELETE /v1/webhook-endpoints/{id} — deactivates the endpoint, returns 204
POST 201 response — the secret appears here ONCE
{
  "id": "3c2b1a0e-9f8d-4c6b-a5e4-d3c2b1a0e9f8",
  "url": "https://yourstore.com/webhooks/rmfg",
  "events": ["order.created", "order.in_review", "order.in_production", "order.shipped", "order.delivered", "order.cancelled", "order.payment_failed"],
  "active": true,
  "created_at": "2026-07-05T12:00:00+00:00",
  "secret": "whsec_2f9d1c4b8a6e3f7d0c5b9a8e7f6d5c4b3a2e1f0d9c8b7a6e5f4d3c2b1a0e9f8d"
}

The whsec_ secret is returned only in this response. Store it immediately — it cannot be retrieved again.

Webhook delivery & signature verification

We POST each event to your endpoint with a 10 second timeout. Any 2xx response marks it delivered; otherwise we retry with backoff (1m, 5m, 30m, 2h, 8h — 6 attempts total over roughly 10.5 hours). Deliveries can arrive out of order and more than once — use the delivery id to de-duplicate.

Delivery headers
Content-Type: application/json
X-RMFG-Event: order.shipped
X-RMFG-Timestamp: 1751712000
X-RMFG-Signature: v1=fb8156e2ce94d2c1ccb20ef218633f689cf4a10aff0cb775a3a1d8b04ed764aa
Delivery body
{
  "id": "8f14e45f-ceea-4f30-aa8f-08bd10e2f011",
  "type": "order.shipped",
  "created_at": "2026-07-05T12:00:00+00:00",
  "data": {
    "order": {
      "id": "k3x9mQ2pL7vD",
      "external_ref": "shopify-1001",
      "status": "shipped",
      "sku": "BRKT-01",
      "quantity": 2,
      "tracking": {
        "carrier": "FedEx",
        "number": "781234567890",
        "url": "https://www.fedex.com/fedextrack/?trknbr=781234567890"
      },
      "shipping_cost_cents": 1450
    }
  }
}

tracking and shipping_cost_cents are null until the order ships.

order.payment_failed fires when a charge to your card fails — the order still ships. Update your card in the dropship dashboard (which retries outstanding charges automatically) or retry them from there directly. Its data carries the order and the failed charge:

Delivery body — order.payment_failed
{
  "id": "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "type": "order.payment_failed",
  "created_at": "2026-07-08T16:20:00+00:00",
  "data": {
    "order": {
      "id": "k3x9mQ2pL7vD",
      "external_ref": "shopify-1001",
      "status": "shipped"
    },
    "charge": {
      "kind": "shipping",
      "amount_cents": 1450,
      "error": "Your card was declined."
    }
  }
}

Verifying signatures

The signature is HMAC-SHA256(secret, timestamp + "." + rawBody) hex-encoded, sent as X-RMFG-Signature: v1=<hex>. Sign the raw request body bytes exactly as received — do not re-serialize the parsed JSON, since key order and whitespace would change the digest. Reject deliveries whose timestamp is more than a few minutes old to prevent replays.

Node.js verification
const crypto = require("crypto");

// rawBody must be the exact request body string/bytes, not re-serialized JSON.
function verifyRmfgWebhook(secret, headers, rawBody) {
  const timestamp = headers["x-rmfg-timestamp"];
  const signature = headers["x-rmfg-signature"]; // "v1=<hex>"
  if (!timestamp || !signature || !signature.startsWith("v1=")) return false;

  // Reject stale deliveries (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const provided = signature.slice("v1=".length);

  const expectedBuf = Buffer.from(expected, "hex");
  const providedBuf = Buffer.from(provided, "hex");
  return (
    expectedBuf.length === providedBuf.length &&
    crypto.timingSafeEqual(expectedBuf, providedBuf)
  );
}

// Express example — capture the raw body before JSON parsing:
// app.post("/webhooks/rmfg", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyRmfgWebhook(process.env.RMFG_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"))) {
//     return res.status(400).send("bad signature");
//   }
//   const event = JSON.parse(req.body);
//   // handle event.type / event.data.order ...
//   res.sendStatus(200);
// });

Worked example

With secret whsec_2f9d1c4b8a6e3f7d0c5b9a8e7f6d5c4b3a2e1f0d9c8b7a6e5f4d3c2b1a0e9f8d, timestamp 1751712000, and this exact raw body (one line, no spaces):

{"id":"8f14e45f-ceea-4f30-aa8f-08bd10e2f011","type":"order.shipped","created_at":"2026-07-05T12:00:00+00:00","data":{"order":{"id":"k3x9mQ2pL7vD","external_ref":"shopify-1001","status":"shipped","sku":"BRKT-01","quantity":2,"tracking":{"carrier":"FedEx","number":"781234567890","url":"https://www.fedex.com/fedextrack/?trknbr=781234567890"},"shipping_cost_cents":1450}}}

the string to sign is 1751712000.{...body...} and the resulting header is:

X-RMFG-Signature: v1=fb8156e2ce94d2c1ccb20ef218633f689cf4a10aff0cb775a3a1d8b04ed764aa