Skip to content

Repository files navigation

Solar Juice Partner API for Ruby

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.

Requirements

Ruby 3.0 or newer.

Install

# Gemfile
gem "solarjuice-partner-api", "~> 1.0"

or

gem install solarjuice-partner-api

Quickstart

require "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"].first

Responses 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.

Authentication

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_...") # explicit

With no key in either place, the constructor raises SolarJuice::PartnerApi::ConfigurationError.

Client options

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>

Resources

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).

Kits

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:

  • price is the kit's own, set by hand against the kit. Summing the components will not reproduce it.
  • weight_kg is 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 from components: 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 reads weight_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)), and total is 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"]     # => 5

Pagination

Every 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 page

auto_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.

Incremental sync

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.

Stock in one state

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 43318

NSW, 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.

Placing an order

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 asynchronous

Cancelling an order

An 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"] # cancelled

The note is optional. Without one the API records cancelled by partner.

Idempotency

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.

Polling an order

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 unchanged

A 304 still counts against your rate limit. To watch many orders at once, poll client.orders.auto_page(updated_since: watermark) instead.

Errors

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.

Retries

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.

Rate limits

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 reads

Testing

The 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.

Connections and threads

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.

Development

bundle install
bundle exec rake          # syntax check and tests
bundle exec rake test

The 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.

Licence

MIT. See LICENSE.

About

Ruby SDK for the Solar Juice Partner API. Zero runtime dependencies, Ruby 3.0+.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages