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.
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:
- A callback URL — an HTTPS endpoint we POST webhooks to. It must be a public host: no
http://, no private or loopback addresses. - 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"
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 };quantitymust be ≥ 1.shipping_address—address1,city, andzipare 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.statusiscompletedfor a settled charge orprocessingfor an initiated bank (ACH) debit.processingis a success — the order ships on an initiated debit; you do not need to wait for settlement.payment.amount_centsis 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.
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.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/..."
}
}
status—OPEN,FULFILLED, orCANCELLED.tracking—nulluntil the order ships, then the carrier, number, and URL.cancellation_status—null,requested,accepted, orrejected.- Unknown ref → 404
{"reason":"not_found"}.
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.
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
| Event | Payload beyond event + external_order_ref |
|---|---|
order.fulfilled | hpd_order_number, tracking: { company, number, url } |
order.cancelled | hpd_order_number, message |
order.cancellation_rejected | hpd_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.
| HTTP | Reason | Meaning | Your move |
|---|---|---|---|
| 401 | unauthorized | Bad or missing API key | Check the Authorization header |
| 403 | https_required | Plaintext request | Use https:// |
| 422 | invalid_order_ref | Ref fails the format | Use ^[A-Za-z0-9._-]{1,64}$ |
| 422 | invalid_line_items | Missing/empty items or quantity < 1 | Fix the line items |
| 422 | unshippable_address | Missing address1/city/zip | Complete the address |
| 422 | unknown_sku | SKU not in the catalog | Remove or correct the SKU |
| 422 | discontinued_sku | SKU no longer carried | Remove the SKU |
| 422 | sku_not_fulfillable | SKU missing fulfillment data | Contact HPD; don't retry as-is |
| 422 | out_of_stock | Insufficient stock | Reduce quantity or wait for restock |
| 422 | shop_not_approved | Account not cleared for intake | Contact HPD |
| 402 | payment_failed | Charge declined | Fix payment method, re-POST same ref |
| 409 | cannot_cancel | Order already paid or in fulfillment | Contact HPD |
| 409 | order_cancelled | Ref belongs to a cancelled order | Use a new ref |
| 429 | daily_limit_exceeded | Rolling 24-hour order limit reached | Contact HPD to raise the limit |
| 502 | vendor_unavailable | Fulfillment vendor unreachable | Retry later; a payment block in the body means money moved — don't re-order |
| 503 | payment_state_unknown | Charge outcome unknown, reconciling | Do not auto-retry. Poll status; contact HPD |
Integration checklist
Everything you need to build on your side:
- A secret store for the API key and webhook secret.
POST /orderswith retry on the sameexternal_order_ref.- A webhook receiver that verifies
X-HPD-Signatureand is idempotent. - A reconciliation poll against
GET /orders/:refas the source of truth.
Questions or credential rotation: contact your HPD integration contact.

