Ruby client for the Solar Juice Partner API.
The Partner API lets an approved sales channel sell Solar Juice stock through its own storefront: read your own price list and sellable inventory, see the specials granted to you, get freight quotes that match the Solar Outlet checkout exactly, and place orders against your trade account.
Zero runtime dependencies. Everything it needs (net/http, json, uri,
time, securerandom) ships with Ruby.
- API reference: https://dev.solarjuice.com.au/docs
- Developer program: https://dev.solarjuice.com.au
- The OpenAPI document this gem is built against:
spec/openapi.yaml
Ruby 3.0 or newer.
# Gemfile
gem "solarjuice-partner-api", "~> 1.0"or
gem install solarjuice-partner-apirequire "solarjuice-partner-api"
client = SolarJuice::PartnerApi.new(api_key: ENV["SOLARJUICE_API_KEY"])
page = client.catalogue.list(limit: 50, brand: "GoodWe")
page["items"].each { |product| puts "#{product['sku']} #{product['price']}" }
quote = client.shipping.quote(
destination: { suburb: "Parramatta", postcode: "2150", state: "NSW" },
lines: [{ sku: "GW-5000-DNS-30", quantity: 1 }]
)
rate = quote["rates"].firstResponses are the decoded JSON body with string keys, exactly as documented in the reference. Nothing is renamed or coerced, so a field added to the API is readable without upgrading the gem.
Keys look like sj_live_<keyid>_<secret> or sj_test_<keyid>_<secret> and are
sent as Authorization: Bearer <key>. There is one host: a sj_test_ key runs
against the same catalogue, prices and inventory, but orders placed with it are
flagged sandbox: true and never reach operations. Build with a test key, then
swap in a live key with no other change.
The key is read from SOLARJUICE_API_KEY when none is passed:
client = SolarJuice::PartnerApi.new # from the environment
client = SolarJuice::PartnerApi.new(api_key: "sj_test_...") # explicitWith no key in either place, the constructor raises
SolarJuice::PartnerApi::ConfigurationError.
| Option | Default | Meaning |
|---|---|---|
api_key: |
ENV["SOLARJUICE_API_KEY"] |
Your partner key |
base_url: |
https://api.solarjuice.com.au |
Override for a proxy or a test double |
timeout: |
30 |
Deadline in seconds for a whole request, connect to last byte |
max_retries: |
3 |
Retries after the first attempt |
user_agent: |
none | Suffix appended to solarjuice-ruby/<version> |
transport: |
NetHttpTransport |
See Testing |
timeout is a deadline for the entire exchange, not a per read timeout, so a
server that stalls or dribbles a response one byte at a time cannot hold a call
open past it. A timeout of zero or less, or a negative max_retries, raises
ConfigurationError at construction.
The API key is never printed. client.inspect, pp client and anything else
that walks the object show the base URL and the options, and no credential:
client.inspect
# => #<SolarJuice::PartnerApi::Client base_url="https://api.solarjuice.com.au" timeout=30 max_retries=3>| Group | Methods |
|---|---|
client.catalogue |
list, auto_page, get(sku) |
client.inventory |
list, auto_page, get(sku, state:) |
client.specials |
list, auto_page |
client.shipping |
quote(body) |
client.orders |
create(body, idempotency_key:), list, auto_page, get(id, if_none_match:), cancel(id, note:) |
client |
health |
Option names match the API's parameter names, in snake case:
client.orders.list(status: "accepted", updated_since: last_sync, limit: 200).
A kit is a bundle sold under one SKU, for example Kit-14406, that holds no
stock of its own and is assembled from ordinary catalogue products. Every
catalogue product carries is_kit; a kit also carries components, a list of
{sku, quantity}, which is absent rather than empty on everything else.
Branch on is_kit and not on the SKU prefix, which is a naming habit and not
part of the contract.
Three of a kit's figures are worked out differently, and assuming otherwise is what makes a partner's numbers disagree with ours:
priceis the kit's own, set by hand against the kit. Summing the components will not reproduce it.weight_kgis the kit's own too, recorded against the kit exactly as it is on any other product. A weight worked out from the components is only a fallback for a kit that has none of its own, so do not rebuild it fromcomponents: your figure would not be ours.- Freight is not priced from
weight_kg, for a kit or for anything else. The quote endpoint plans every consignment from the product's shipping specification, its packed dimensions and the weight recorded there, and never readsweight_kg, which is published for information only. Do not pre-estimate freight from it and then reconcile against our quote; the two are allowed to differ. Quote the cart and read the rate. Quote and order the kit SKU, never its parts. - Availability is derived. Per metro it is
floor(min over components of (component_available / quantity)), andtotalis the sum of those per metro figures, not a minimum taken against national component totals. A kit ships from a single metro, so a battery in Perth cannot complete a kit in Sydney; if your own arithmetic gives a larger number, that is why.
A kit is left out of the catalogue and the inventory feed altogether, rather than reported as zero, when a component is inactive or missing, when a component's quantity is not positive, when it has no components at all, or when its weight cannot be resolved either from itself or from its components. Withholding is the safer failure: we would rather not list a bundle than list one we cannot describe accurately.
Being listed means the kit can be ordered. It does not guarantee an automatic
freight rate: publication needs a resolvable weight, while quoting also needs
the full packed dimensions. Every kit publishing today has them, so in
practice what you will meet is a kit quoting manual_quote_required, usually
because one unit is heavier than a standard pallet movement allows. That is a
normal outcome rather than a fault, and retrying will not change it: a person
prices the freight instead.
Kit delivery is switched on per channel and is off by default. With it off you
see no kits at all: a kit SKU is indistinguishable from one that does not
exist, and nothing else about the responses changes, so is_kit is still on
every product you can see and is simply always false. If you expect kits and
cannot see any, ask your Solar Juice account manager to enable kit delivery
rather than looking for a fault in your client.
product = client.catalogue.get("Kit-14406")
if product["is_kit"]
parts = product["components"].map { |c| "#{c['quantity']} x #{c['sku']}" }.join(", ")
puts "#{product['sku']} sells for #{product['price']} and contains #{parts}"
end
# How many you can sell is a separate call. Never infer it from the parts.
stock = client.inventory.get("Kit-14406")
stock["available"] # => { "Sydney" => 4, "Melbourne" => 1 }
stock["total"] # => 5Every list endpoint is cursor paginated and returns the envelope
{ "as_of", "items", "next_cursor" } (catalogue adds price_list_version).
list gives you one page:
page = client.inventory.list(limit: 200)
page["items"]
page["next_cursor"] # nil on the last pageauto_page gives you an Enumerator over the items of every page. It is lazy:
pages are fetched as they are needed, so taking the first 50 items costs one
request, not a whole catalogue walk.
client.catalogue.auto_page(brand: "GoodWe").each do |product|
upsert(product)
end
first_fifty = client.catalogue.auto_page.lazy.first(50)If the API ever hands back the cursor it was just given, auto_page raises
SolarJuice::PartnerApi::Error with the code PAGINATION_STALLED rather than
paging forever and spending your whole rate allowance on one loop.
updated_since takes the as_of from your previous response. The filter runs
off the API's own change sequence rather than record edit times, so nothing is
skipped because of clock differences.
page = client.catalogue.list
watermark = page["as_of"]
# later
client.catalogue.auto_page(updated_since: watermark).each { |product| upsert(product) }Inventory rows that have dropped to zero are still returned by an
updated_since query, with total: 0, so you can clear them locally.
Pass state: and the whole answer is scoped to it: available is keyed by the
state and total is that state's stock rather than the national figure, so
there is nothing left for you to add up.
client.inventory.auto_page(state: "VIC").each do |item|
set_victorian_stock(item["sku"], item["total"]) # Melbourne's figure
end
# Queensland is served from Brisbane AND Townsville and comes back as their
# sum, so a SKU stocked only in Townsville is Queensland stock.
qld = client.inventory.get("20571", state: "QLD")
qld["available"] # => { "QLD" => 12302 } (9494 Brisbane + 2808 Townsville)
qld["total"] # => 12302, not the national 43318NSW, VIC, QLD, WA and SA are the states Solar Juice stocks. Case is
ignored and the spelt out name works, so VIC, vic and Victoria are the
same filter. There is no warehouse in NT, TAS or ACT, so those raise a
400 rather than returning an empty list that would read as "out of stock
everywhere". Omit state: and you get every metro and the national total,
exactly as before. Pass nil to leave it out, never "": an empty string is
sent as ?state= and is a 400, deliberately, because a blank state answered
with national figures is how a partner ends up selling stock that is in
another state.
An order needs a priced, unexpired quote, the price_list_version the cart
was priced from, and unit_price values that match the current catalogue.
receipt = client.orders.create(
client_reference: "PO-88213", # your reference, and the idempotency key
price_list_version: client.price_list_version,
quote_id: quote["quote_id"],
rate_service_code: rate["service_code"],
delivery: {
name: "Jane Citizen", phone: "+61400000000",
address1: "12 Example Street", suburb: "Parramatta",
postcode: "2150", state: "NSW"
},
lines: [{ sku: "GW-5000-DNS-30", quantity: 1, unit_price: "1110.99" }]
)
receipt["id"] # ord_01J6ZK3M5X8QW2R7Y9V4B1N0PD
receipt["status"] # received, because acceptance is asynchronousAn order can be cancelled while it is received, accepted or on_hold,
which in practice means before operations key it into the fulfilment system.
After that the API refuses with ValidationFailedError and the cancellation
has to go through your account manager. cancelled is terminal; there is no
un-cancel.
order = client.orders.cancel(receipt["id"], note: "Customer changed the panel selection")
order["status"] # cancelledThe note is optional. Without one the API records cancelled by partner.
client_reference in the body is the only idempotency key. Resubmitting the
same reference with the same body returns the order that already exists (200
rather than the first call's 202); a different body raises
SolarJuice::PartnerApi::IdempotencyConflictError. Sandbox and live keys have
separate reference namespaces.
orders.create also sends an Idempotency-Key header, yours if you pass
idempotency_key: and a generated UUID v4 otherwise, and puts it on the
result. The API accepts that header and ignores it: it is not stored, not
compared and not returned, so it is a local correlation value for your own
logs. After a create that timed out, find the order by your own reference
instead:
page = client.orders.list(client_reference: "PO-88213")Shipping quotes are cached rather than idempotent. A repeated quote for an
unchanged cart, destination and origin returns the same quote_id while it has
at least five minutes of validity left, and a new one after that. Read
quote_id and expires_at off the response you have rather than assuming
either behaviour.
orders.get accepts an ETag. When nothing has changed the API answers 304,
which this gem reports rather than raising:
order = client.orders.get(order_id)
etag = order.etag
sleep 30
latest = client.orders.get(order_id, if_none_match: etag)
latest.not_modified? # true while the order is unchangedA 304 still counts against your rate limit. To watch many orders at once, poll
client.orders.auto_page(updated_since: watermark) instead.
Every error raised by this gem descends from SolarJuice::PartnerApi::Error,
and every error the API returns descends from ApiError, with one subclass per
documented error code.
begin
client.orders.create(body)
rescue SolarJuice::PartnerApi::PriceChangedError => e
# details is a list of free-form objects whose keys depend on the code. For
# PRICE_CHANGED it is the version pair followed by one entry per moved line:
# [{ "price_list_version" => "plv_4c81ba09e7d2f6",
# "current_price_list_version" => "plv_9f3a2c1d84b6e05" },
# { "sku" => "GW-5000-DNS-30", "unit_price" => "1110.99",
# "current_price" => "1099.00" }]
e.details
refresh_catalogue_and_retry
rescue SolarJuice::PartnerApi::RateLimitedError => e
sleep(e.retry_after || 60)
rescue SolarJuice::PartnerApi::ApiError => e
logger.error("#{e.code} #{e.message} request_id=#{e.request_id}")
end| Class | Code | HTTP |
|---|---|---|
UnauthorizedError |
UNAUTHORIZED |
401 |
ForbiddenError |
FORBIDDEN |
403 |
NotFoundError |
NOT_FOUND |
404 |
ValidationFailedError |
VALIDATION_FAILED |
422 |
RateLimitedError |
RATE_LIMITED |
429 |
PriceChangedError |
PRICE_CHANGED |
409 |
IdempotencyConflictError |
IDEMPOTENCY_CONFLICT |
409 |
QuoteUnavailableError |
QUOTE_UNAVAILABLE |
503 |
StaleDataError |
STALE_DATA |
503 |
InternalError |
INTERNAL |
500 |
Every one carries code, message, details, request_id and status_code.
Quote request_id when you raise a support request.
An unknown code raises ApiError itself with the code the API sent. When the
response carries no error envelope at all, which is what an edge proxy's own
HTML page looks like, code is filled in from the status (401 UNAUTHORIZED,
403 FORBIDDEN, 404 NOT_FOUND, 422 VALIDATION_FAILED, 429 RATE_LIMITED,
500 INTERNAL) so e.code == "RATE_LIMITED" still holds. 409 and 503 each
cover two codes, so those keep a nil code and raise ApiError.
retry_after on RateLimitedError is a whole number of seconds, rounded up,
whether the header arrived as seconds or as an HTTP date.
Network failures and timeouts raise TransportError and TimeoutError, which
carry no status code because no response arrived.
429, 502, 503, 504 and network failures are retried up to max_retries times
with exponential backoff starting at 500ms, doubling, with full jitter, capped
at 8 seconds. A Retry-After header wins over the computed delay, up to 60
seconds. Beyond that it is not slept at all: the error is raised straight away
with the real value on retry_after, because an edge proxy asking for an hour
should not park a worker for one. No other 4xx is retried. The POST endpoints
are safe to retry: quotes have no side effect, orders are deduplicated by
client_reference, which does not change between attempts, and a cancellation
that already happened is refused rather than repeated.
Set max_retries: 0 to handle retries yourself.
Limits are per key over a sliding one minute window, 600 requests per minute by default. The last seen values sit on the client:
client.rate_limit.limit # 600
client.rate_limit.remaining # 597
client.rate_limit.reset # seconds until the window rolls over
client.last_request_id # X-Request-Id of the last response
client.price_list_version # X-Price-List-Version, set by catalogue readsThe HTTP layer is injectable. A transport is anything that responds to
#call(request) and returns a SolarJuice::PartnerApi::Transport::Response, so
tests need no network and no stubbing library:
class FakeTransport
def call(request)
SolarJuice::PartnerApi::Transport::Response.new(
status: 200,
headers: { "X-Request-Id" => "req_test" },
body: JSON.generate({ "as_of" => "2026-09-02T04:10:11Z", "items" => [], "next_cursor" => nil })
)
end
end
client = SolarJuice::PartnerApi.new(api_key: "sj_test_key", transport: FakeTransport.new)Raise TransportError from #call to exercise the retry path.
The default transport keeps one connection per host open and reuses it, so a
paging sync pays for one TLS handshake rather than one per page. Calls on a
single client are serialised on a mutex because a connection carries one request
at a time. For parallel work, build one client per thread. Call client.close
when you are finished with a client that will not be reused.
bundle install
bundle exec rake # syntax check and tests
bundle exec rake testThe suite runs entirely against a stubbed transport. test/conformance_test.rb
parses spec/openapi.yaml and fails if the API grows an operation, a query
parameter or an error code this gem does not implement.
test/fixtures/error-mapping.json is the shared behaviour table: one response
in, one error class, error code and retry decision out. The Node and PHP
clients replay the same file byte for byte, so the three cannot drift apart
unnoticed.
MIT. See LICENSE.