Skip to content
Please don't forget to add WINTER SHIPPING PROTECTION to your orders if average daytime temps drop below 50°F
Please don't forget to add WINTER SHIPPING PROTECTION to your orders if average daytime temps drop below 50°F

House Plant Dropship · Partner Reference

External Store API

Submit orders to HPD fulfillment from any storefront — your own site, a marketplace, an ERP. Four endpoints, one idempotency key, signed webhooks. JSON in, JSON out.

Base URL https://hpd-merchant-9efe8d988af6.herokuapp.com/external_api/v1

Authentication

Every request carries a Bearer API key:

Authorization: Bearer hpd_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • HPD issues your API key and a separate webhook secret during onboarding. Both are shown once and cannot be recovered — store them in your secret manager immediately.
  • A missing, malformed, or unknown key returns 401 {"reason":"unauthorized"}.
  • HTTPS is required. A plaintext http:// request is refused with 403 {"reason":"https_required"} before the key is even read. Treat the key like a password — it authorizes creating orders and charging your account.
  • Order intake is rate-limited per shop over a rolling 24-hour window, set well above normal volume. Exceeding it returns 429 {"reason":"daily_limit_exceeded"} — contact HPD to raise your limit.

Versioning: the version lives in the path (/v1). A breaking change ships as /v2; /v1 keeps working.

Onboarding

API access requires an active House Plant Dropship membership. Credentials are provisioned by HPD staff — there is no self-serve signup. You provide:

  1. A callback URL — an HTTPS endpoint we POST webhooks to. It must be a public host: no http://, no private or loopback addresses.
  2. Your payment method — set up during onboarding so charges can settle. Charges are processed through Stripe (bank debit or card, per your setup).

HPD then returns your API key and webhook secret and confirms the callback URL. To rotate credentials, contact HPD. Rotation invalidates the old key immediately — there is no overlap window — so coordinate the cutover.

Quick start

Submit an order, then read it back. The response to the POST already tells you whether the order was accepted and paid — there is no separate confirmation step.

curl -X POST https://hpd-merchant-9efe8d988af6.herokuapp.com/external_api/v1/orders \
  -H "Authorization: Bearer $HPD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_order_ref": "RB-1042",
    "line_items": [{ "sku": "MON-DEL-4", "quantity": 2 }],
    "shipping_address": {
      "first_name": "Nora", "last_name": "Chen",
      "address1": "12 Fern Ct", "city": "Boise",
      "province": "ID", "zip": "83702", "country_code": "US"
    }
  }'

# later: poll status by your own order ref
curl https://hpd-merchant-9efe8d988af6.herokuapp.com/external_api/v1/orders/RB-1042 \
  -H "Authorization: Bearer $HPD_API_KEY"
POST /orders

Submit an order

Synchronous: the response tells you whether the order was accepted and paid. Idempotent on external_order_ref — retrying the same ref is always safe (see Idempotency & retries).

Request body

{
  "external_order_ref": "RB-1042",
  "line_items": [
    { "sku": "MON-DEL-4", "quantity": 2 }
  ],
  "shipping_address": {
    "first_name": "Nora",
    "last_name": "Chen",
    "address1": "12 Fern Ct",
    "address2": null,
    "city": "Boise",
    "province": "ID",
    "zip": "83702",
    "country_code": "US",
    "phone": null,
    "company": null
  }
}
  • external_order_ref — your order id. Must match ^[A-Za-z0-9._-]{1,64}$. This is the key for every later status and cancel call, and for webhook correlation.
  • line_items — one or more { sku, quantity }; quantity must be ≥ 1.
  • shipping_addressaddress1, city, and zip are required; the rest are optional.

Success — 201 Created (first time) or 200 OK (re-POST of an accepted order)

{
  "order_id": 84,
  "hpd_order_number": "HPD-77",
  "status": "accepted",
  "payment": {
    "status": "completed",
    "amount_cents": 4630,
    "method_type": "us_bank_account"
  },
  "line_items": [
    { "sku": "MON-DEL-4", "quantity": 2, "title": "Monstera Deliciosa 4\"" }
  ]
}
  • payment.status is completed for a settled charge or processing for an initiated bank (ACH) debit. processing is a success — the order ships on an initiated debit; you do not need to wait for settlement.
  • payment.amount_cents is the amount charged. There is never a separate card or processing "fee" line — pricing is quoted as a single total.

Failures

See the error reference for the full table. The two that need special handling:

  • 402 payment_failed — the charge was declined. Fix the payment method, then re-POST the same ref to retry.
  • 503 payment_state_unknown — see the warning below.
Do not auto-retry a 503 payment_state_unknown. The charge outcome is genuinely unknown and is being reconciled by HPD. A re-POST of the same ref is safe (it never double-charges) but will keep returning 503 until reconciliation clears. Surface "contact HPD" and poll GET /orders/:ref.
GET /orders/:external_order_ref

Order status

Your source of truth. Poll it whenever you need current state, and as the fallback if a webhook is missed.

{
  "external_order_ref": "RB-1042",
  "order_id": 84,
  "hpd_order_number": "HPD-77",
  "status": "OPEN",
  "payment": {
    "status": "completed",
    "amount_cents": 4630,
    "method_type": "us_bank_account"
  },
  "cancellation_status": null,
  "tracking": {
    "company": "USPS",
    "number": "9400...",
    "url": "https://tools.usps.com/..."
  }
}
  • statusOPEN, FULFILLED, or CANCELLED.
  • trackingnull until the order ships, then the carrier, number, and URL.
  • cancellation_statusnull, requested, accepted, or rejected.
  • Unknown ref → 404 {"reason":"not_found"}.
POST /orders/:external_order_ref/cancel

Cancel an order

Cancels an order that has not yet been paid and sent to fulfillment.

  • 200 OK {"external_order_ref":"RB-1042","cancellation_status":"accepted"} — the order was cancelled.
  • 409 {"reason":"cannot_cancel"} — the order is already paid, already with the fulfillment vendor, or has a charge in flight. It cannot be cancelled through the API; contact HPD.
  • 404 unknown ref; 502 if the fulfillment vendor is unreachable — retry later.

Once an order is CANCELLED, re-POSTing the same ref returns 409 {"reason":"order_cancelled"}. Use a new external_order_ref for a fresh order.

GET /inventory

Inventory

Full snapshot of available stock by SKU. Poll every 10–15 minutes.

{ "inventory": { "MON-DEL-4": 12, "PHIL-BIRK-6": 3 } }

Values can be briefly stale — they mirror an internal cache. The order-time stock gate is authoritative, so an order may still be rejected out_of_stock even if inventory looked sufficient.

Idempotency & retries

external_order_ref is the idempotency key. Retrying POST /orders with the same ref is always safe:

  • If the first attempt charged, the retry returns the current state (200) and never charges again.
  • If the first attempt declined (402), the retry re-attempts the charge — use this after the payment method is fixed.
  • If the outcome was unknown (503), the retry stays 503 until HPD reconciles; it never double-charges.

Reuse a ref only for genuine retries of the same order. A cancelled ref cannot be reused (409 order_cancelled); start a new order with a new ref.

Webhooks

HPD POSTs events to your callback URL as they happen. Webhooks are an optimization — GET /orders/:ref is the source of truth. Delivery is at-least-once: you may receive the same event more than once, so make your handler idempotent.

Events

EventPayload beyond event + external_order_ref
order.fulfilledhpd_order_number, tracking: { company, number, url }
order.cancelledhpd_order_number, message
order.cancellation_rejectedhpd_order_number, message
{
  "event": "order.fulfilled",
  "external_order_ref": "RB-1042",
  "hpd_order_number": "HPD-77",
  "tracking": { "company": "USPS", "number": "9400...", "url": "https://..." }
}

Verifying the signature

Every delivery carries an HMAC-SHA256 signature of the raw request body, keyed with your webhook secret (not your API key):

X-HPD-Signature: sha256=<hex HMAC-SHA256(raw_body, webhook_secret)>

Verify it before trusting the payload: compute the HMAC over the exact bytes you received and compare in constant time.

# Ruby
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV["HPD_WEBHOOK_SECRET"], raw_body)
Rack::Utils.secure_compare(expected, request.headers["X-HPD-Signature"].to_s)
# Python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
hmac.compare_digest(expected, request.headers.get("X-HPD-Signature", ""))
// Node
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get("X-HPD-Signature") || ""));

Retries & reliability

If your endpoint doesn't return a 2xx, HPD retries with exponential backoff over roughly a day, then gives up. Retries resend the identical body, and a delivery can be missed entirely — so reconcile against GET /orders/:ref rather than relying on webhooks alone. Respond 2xx quickly and do the work asynchronously.

Error reference

Every error body is {"reason": "...", "detail": "..."}. Nothing is persisted or charged for a 422 — those gates run before any order is created.

HTTPReasonMeaningYour move
401unauthorizedBad or missing API keyCheck the Authorization header
403https_requiredPlaintext requestUse https://
422invalid_order_refRef fails the formatUse ^[A-Za-z0-9._-]{1,64}$
422invalid_line_itemsMissing/empty items or quantity < 1Fix the line items
422unshippable_addressMissing address1/city/zipComplete the address
422unknown_skuSKU not in the catalogRemove or correct the SKU
422discontinued_skuSKU no longer carriedRemove the SKU
422sku_not_fulfillableSKU missing fulfillment dataContact HPD; don't retry as-is
422out_of_stockInsufficient stockReduce quantity or wait for restock
422shop_not_approvedAccount not cleared for intakeContact HPD
402payment_failedCharge declinedFix payment method, re-POST same ref
409cannot_cancelOrder already paid or in fulfillmentContact HPD
409order_cancelledRef belongs to a cancelled orderUse a new ref
429daily_limit_exceededRolling 24-hour order limit reachedContact HPD to raise the limit
502vendor_unavailableFulfillment vendor unreachableRetry later; a payment block in the body means money moved — don't re-order
503payment_state_unknownCharge outcome unknown, reconcilingDo not auto-retry. Poll status; contact HPD

Integration checklist

Everything you need to build on your side:

  1. A secret store for the API key and webhook secret.
  2. POST /orders with retry on the same external_order_ref.
  3. A webhook receiver that verifies X-HPD-Signature and is idempotent.
  4. A reconciliation poll against GET /orders/:ref as the source of truth.

Questions or credential rotation: contact your HPD integration contact.