growwer / growwer-api-sdk
Official PHP client for the Growwer API: orders, projects, the media catalogue, seller medias and wallets.
Package info
github.com/viralitymedia/growwer-api-sdk
Language:Java
pkg:composer/growwer/growwer-api-sdk
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.3
- guzzlehttp/psr7: ^1.7 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.5
- phpunit/phpunit: ^8.0 || ^9.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-12 08:55:31 UTC
README
Official clients for the Growwer customer API: your account, projects, orders, the media catalogue, your seller medias and orders, wallets, the managed service, webhook subscriptions and the test mode helpers.
Every client here is generated from the OpenAPI contract the API itself publishes, so they say what the running service does rather than what a handwritten wrapper remembers about it. One contract, one generator run, one version: a change to the API reaches all seven languages together.
| Language | Package | Version | Directory |
|---|---|---|---|
| Python 3.10+ | growwer |
python/ |
|
| TypeScript (Node 22+, browsers) | @growwer/api |
typescript/ |
|
| PHP 8.1+ | growwer/growwer-api-sdk |
php/ |
|
| Go 1.23+ | github.com/viralitymedia/growwer-api-sdk/go/v2 |
go/ |
|
| Java 11+ | com.growwer:growwer-api |
java/ |
|
| .NET 8 | Growwer.Api |
csharp/ |
|
| Ruby 3.0+ | growwer |
ruby/ |
Each directory carries the generated reference for its language: one page per resource and one per
model under its own docs/.
Get a key
Create one on your Growwer profile page, under API keys. Name it after the integration that will use it, and pick what it may do:
| Key | What it allows |
|---|---|
| Read only | Every GET operation: your account, projects, the catalogue, orders and wallets. |
| Read and write | Also every write: creating and changing projects, placing and acting on orders — and spending your balance. |
The key is shown once, when you create it, and never again: Growwer stores only a fingerprint of it. Copy it then and keep it where you keep any other secret. If you lose it, revoke it and create another; a revoked key stops working at once.
Send it as a bearer token on every request: Authorization: Bearer <key>.
The same page creates test keys, which start with gw_sk_test_ and work on test data apart
from your real account: see Test mode.
First call
getMe returns the account the key belongs to: its wallet balances, its seller status and its
daily catalogue quota. It is the quickest way to confirm a key works.
Every client already points at https://tool.growwer.com. The examples show where to change that,
for instance to call another host.
Python
import os from growwer import ApiClient, Configuration from growwer.api import AccountApi, BuyerOrdersApi config = Configuration( host="https://tool.growwer.com", # optional: this is the default access_token=os.environ["GROWWER_API_KEY"], ) with ApiClient(config) as client: me = AccountApi(client).get_me() print(me.email, me.buyer.balance.available.amount, me.buyer.balance.available.currency) page = BuyerOrdersApi(client).list_orders(limit=100) print(f"{len(page.items)} of {page.total} orders")
TypeScript
import { Configuration, AccountApi, BuyerOrdersApi } from "@growwer/api" const config = new Configuration({ basePath: "https://tool.growwer.com", // optional: this is the default accessToken: process.env.GROWWER_API_KEY, }) const me = await new AccountApi(config).getMe() console.log(me.email, me.buyer.balance.available.amount) const page = await new BuyerOrdersApi(config).listOrders({ limit: 100 }) console.log(`${page.items.length} of ${page.total} orders`)
Go
import ( "context" "os" growwer "github.com/viralitymedia/growwer-api-sdk/go/v2" ) config := growwer.NewConfiguration() // Optional: this is the default. config.Servers = growwer.ServerConfigurations{{URL: "https://tool.growwer.com"}} client := growwer.NewAPIClient(config) ctx := context.WithValue(context.Background(), growwer.ContextAccessToken, os.Getenv("GROWWER_API_KEY")) me, _, err := client.AccountAPI.GetMe(ctx).Execute()
PHP
use Growwer\Api\Api\AccountApi; use Growwer\Api\Configuration; $config = Configuration::getDefaultConfiguration() ->setHost('https://tool.growwer.com') // optional: this is the default ->setAccessToken(getenv('GROWWER_API_KEY')); $me = (new AccountApi(new GuzzleHttp\Client(), $config))->getMe();
Ruby
require "growwer" Growwer.configure do |config| config.host = "tool.growwer.com" # optional: this is the default config.access_token = ENV.fetch("GROWWER_API_KEY") end me = Growwer::AccountApi.new.get_me
Java
import com.growwer.api.ApiClient; import com.growwer.api.api.AccountApi; ApiClient client = new ApiClient(); client.setBasePath("https://tool.growwer.com"); // optional: this is the default client.setBearerToken(System.getenv("GROWWER_API_KEY")); var me = new AccountApi(client).getMe();
C#
using Growwer.Api.Api; using Growwer.Api.Client; var config = new Configuration { BasePath = "https://tool.growwer.com", // optional: this is the default AccessToken = Environment.GetEnvironmentVariable("GROWWER_API_KEY"), }; var me = new AccountApi(config).GetMe();
Changes in 2.2.0
Some code written for 2.1.0 breaks even though this is a minor version: SellerMedia gains a
required, nullable competitiveFeesByLinkType, and applySellerMediaCompetitiveFees no longer
answers 409 no_competitive_fees. No path or type moved.
applySellerMediaCompetitiveFeesanswers 200 when there is nothing to lower. It used to answer409 no_competitive_feeswhen Growwer had no suggestion for the media; now it answers 200 with the media unchanged, so code that handled that 409 never sees it: a 200 now also answers when Growwer has no suggestion. ReadcompetitiveFeesfirst, which is null when there is nothing to lower, or compare the fees it answers with the ones you had. A 409 is now only anIdempotency-Keyconflict. What the call sets changed too: it lowers each link type's fees on its own and never raises one, so after it a link type's fee can be belowcompetitiveFees.- Go:
NewSellerMediagainscompetitiveFeesByLinkTypebetweencompetitiveFeesandcreatedAt. A nilCompetitiveFeesByLinkTypeis dropped when aSellerMediais marshalled, and the client then refuses that JSON, because the key is required: to cache or mock a seller media read with a null list, set an empty slice before marshalling it (it reads back as an empty list, not nil), or keep the raw JSON. - C#: the
SellerMediaconstructor gainscompetitiveFeesByLinkTypebeforecreatedAt, so a call that passescreatedAtby position no longer compiles. Any call that does not pass a non-nullcompetitiveFeesByLinkType, named arguments included, compiles and throws anArgumentNullException, though reading a response with a null one works; to build one with a null list by hand, pass a list, then setCompetitiveFeesByLinkType = null. Theenabledparameter of theUpdateWebhookRequestconstructor and thedraftReviewableone of theOrderEstimateRequestandOrderCreateRequestconstructors becomebool?(the properties staybool): code that passes aboolcompiles as before, but code built against 2.1.0 must be recompiled. - Mocks and recorded responses without
competitiveFeesByLinkType: a seller media JSON recorded before 2.2.0 now fails to parse in Go, Java and C#. Python still reads one, asNone, but refuses aSellerMediabuilt by hand without it, and a TypeScript object-literal mock without it no longer type-checks; TypeScript'sSellerMediaFromJSONreads a missing one as null, andinstanceOfSellerMediaanswers false for it. PHP and Ruby read and build one as before, and theirvalid()(PHP),valid?andlist_invalid_properties(Ruby) do not flag it, because the field may be null.
What changes:
- Competitive fees per link type.
SellerMedia.competitiveFeesByLinkTypelists the feesapplySellerMediaCompetitiveFeeswould set, oneSellerMediaCompetitiveFeesByLinkTypeInnerper link type it lowers, in the order dofollow, nofollow, citation: alinkType, and afeeWhenSellerWritesPostandfeeWhenSellerDoesNotWritePostthat are null where it keeps your fee. It is null exactly whencompetitiveFeesis null, andcompetitiveFeesis now the highest of these per side. amountDecimalon Money. Every Money the API answers,MoneyandMoneyOrNull, carriesamountDecimal, the same amount as an exact decimal string in major units:12500cents is"125.00". It is optional and read-only, so no response, mock or request built for 2.1.0 breaks. A request may leave it out or send it back exactly as it was answered; any other value is refused with422 validation_failed. Python, TypeScript and C# never send it, but Go, Java, PHP and Ruby send the one a Money was read with, so do not change theamountof a Money read from a response and send it: build a new Money fromamountandcurrency. See Money and dates.- C#: an
updateWebhookwithoutEnabledno longer disables the subscription. AnUpdateWebhookRequestthat never setEnabledsent"enabled": false, so changing only the URL, the events, the threshold or the description stopped the subscription's events. It now leavesenabledout, and the subscription keeps its state;Enabled = falseortrue, set through the property or passed to the constructor, is still sent. A subscription an update from a 2.1.0 C# client disabled stays disabled after upgrading: readEnabledwithGetWebhookorListWebhooksand sendEnabled = truethroughUpdateWebhookto enable it again.OrderEstimateRequestandOrderCreateRequestleave an unsetDraftReviewableout the same way, which the API reads as false, as before. The constructor parametersenabledanddraftReviewablebecomebool?; see the C# break above. - Descriptions only: the catalogue's
403 catalogue_requires_recent_orderand the trial refusal of429 catalogue_daily_cap_reachedname their threshold indetails.minOrdersanddetails.windowDays, and count an order only once it is delivered and approved (see The catalogue); the API description documentsamountDecimal.
Changes in 2.1.0
Some code written for 2.0.0 breaks even though this is a minor version: getMe now answers a
required mode, and its seller can be null, which it is for a test key. No other
path, field or type moved.
- Go:
GetMe200Response.Selleris now aNullableGetMe200ResponseSeller: read it withme.Seller.Get(), which returnsnilfor a test key.NewGetMe200Responsetakesselleras aNullableGetMe200ResponseSellerand gainsmodeas its last parameter. - TypeScript:
me.sellerisGetMe200ResponseSeller | null, so code that reads its fields without a null check no longer type-checks understrictNullChecks. - Python, PHP, Java, C# and Ruby: the types still compile, but code that reads
me.seller's fields without a null check fails at run time on a test key. - Mocks and recorded responses without
mode: agetMeJSON recorded before 2.1.0 now fails to parse in Go, Java, C# and Python, Python also refuses aGetMe200Responsebuilt by hand without it, and a TypeScript object-literal mock without it no longer type-checks. PHP and Ruby still read and build one; only theirvalid()(PHP),valid?andlist_invalid_properties(Ruby) flag the missingmode. - Kotlin, on the Java artifact:
getSeller()is now@Nullableinstead of@Nonnull, some.seller.holidayEndDateno longer compiles; useme.seller?.holidayEndDate. - C#: the constructor gains
modeas its last parameter. It still refuses anullsellerwith anArgumentNullException, though reading a response with a nullsellerworks; to build one by hand, assignSeller = nullafter constructing it.
What changes:
- Webhooks.
WebhooksApisubscribes your HTTPS endpoints to events and follows their deliveries:listWebhooks,createWebhook,getWebhook,updateWebhook,deleteWebhook,listWebhookDeliveries,redeliverWebhook,rotateWebhookSecretandsendWebhookTest. TheWebhookEventmodel parses what Growwer POSTs: itsdatais anOrderEventData, aBalanceLowEventDataor aWebhookTestEventData. See Webhooks. - Test mode. Test keys (
gw_sk_test_) work on test data apart from your real account, andgetMesays which mode a key is in.TestHelpersApiaddstopUpTestWallet,advanceTestOrderandresetTestData, for test keys only. See Test mode. - New models:
Webhook,WebhookWithSecret,WebhookEventType,WebhookDelivery,WebhookDeliveryAttempt,WebhookEvent,WebhookEventData,OrderEventData,BalanceLowEventData,BalanceLowEventDataWallet,WebhookTestEventData,CreateWebhookRequest,UpdateWebhookRequest,ListWebhooks200Response,ListWebhookDeliveries200Response,AdvanceTestOrderRequest,TopUpTestWalletRequestandTopUpTestWallet200Response. - In TypeScript and Java,
eventsinCreateWebhookRequestandUpdateWebhookRequestis aSet, because the contract allows each event once: in TypeScript,new Set<WebhookEventType>(["order.completed"]).
Changes in 2.0.0
Two changes break code written for 1.1.1, and neither comes from the API. Python, PHP, Java, C# and Ruby need no change:
- TypeScript:
@growwer/apinow declares Node 22 or newer. Browsers are not affected. - Go: the module moves to
github.com/viralitymedia/growwer-api-sdk/go/v2, because Go requires a second major version to carry/v2. Update every import and rungo get github.com/viralitymedia/growwer-api-sdk/go/v2@v2.0.0. The package is stillgrowwer, and the old path stays at 1.1.1.
The contract adds one enum value and has no breaking change: no path, field or type moved.
- Channel
mcp.Order.channel.created,SellerOrder.channel.createdandSellerOrder.channel.acceptedcan now bemcp, for an order placed or accepted through the MCP server. The Python, PHP, Java, C# and Ruby clients of 1.1.1 refuse that value when they read such an order; this release accepts it. Code that switches over every channel needs a case for it, and in C# the numeric value ofWebmoves from 2 to 3.
Changes in 1.1.1
No code written for 1.1.0 breaks. The contract changes only in what the API documents and in one error a method can now return:
- Catalogue daily cap. The cap now counts units, not only distinct medias: each new media still
costs one, and a catalogue response that holds no new media, an empty page or a
totalread included, costs one unit. Repeating a request already answered the same UTC day is free while the project's media lists, topics and language stay as they were.countSavedFilterMediasis now subject to the catalogue access and the cap, so it can answer403(no catalogue access) and429 catalogue_daily_cap_reached; the first count of a filter in a UTC day costs one unit. - URLs without
<or>. The post URL ofdeliverSellerOrderanddeliverSellerOrderUpdateand thebriefing.linkingFromof a link addition order are refused with a422when they contain<or>. - Proposals summary PDF.
getManagedServiceProposalsSummaryrenders one summary per user at a time; a request while another is being rendered gets429 proposals_summary_in_progress, to retry afterRetry-After.
Changes in 1.1.0
Some code written for 1.0.2 breaks even though this is a minor version: constructor calls,
a SellerMedia built by hand, and in Java and C# two runtime changes that come with the partial
update fix below:
- Go:
NewUpdateSellerMediaRequestPostOffernow takes no arguments, becausepostOffer.pricingOptionsis optional in an update. Other constructors gain required parameters:NewSellerMediagainstypology;NewSellerMediaPostOffergainsmaximumLinks,advertisingLabellingPolicyandpromotedSocialNetworks;NewProjectCatalogueMediaAllOfProjectgainsaiKeywords. - C#: the
UpdateSellerMediaRequestconstructor now takestypologyfirst, so a positional call no longer compiles; name the arguments. TheSellerMediaconstructor insertstypologyafterurl, andSellerMediaPostOfferinsertsmaximumLinks,advertisingLabellingPolicyandpromotedSocialNetworksbeforeextraServices. - C# nulls passed to a constructor: a
nullpassed as a constructor argument is no longer sent, sonew UpdateSellerMediaRequest(postOffer: p, linkAdditionOffer: null)now keeps link additions andnew UpdateProjectRequest(name: null)keeps the name. To sendnull, assign the property:LinkAdditionOffer = null,Name = null. - Java null lists and maps: every list and map property now starts as
nullinstead of an empty container, so code that reads one without a null check, such aserr.getDetails().isEmpty()ornew UpdateProjectRequest().getTopics().add(12), throws aNullPointerException. Use theadd...Itemmethods, which create the list. - Python and Ruby: a
SellerMediaor catalogue media built without the new required fields (typology, the post offer's new fields,project.aiKeywords), as test mocks often are, now fails validation. TypeScript: such an object literal no longer type-checks.
What changes:
- Register and delete your medias.
SellerMediasApiaddscreateSellerMedia(POST /seller/medias) anddeleteSellerMedia(DELETE /seller/medias/{id}). A media registered already is refused with409 media_already_registered, and those refusals count toward a daily limit (429 media_duplicate_limit_reached). A delete is refused with409 media_has_active_orders, listingdetails.orderIds, while any order on the media is not finished. There is no batch operation: to register many medias, callcreateSellerMediaonce per media, each with its ownIdempotency-Key. - More of the offer is editable. Seller medias add
typologyinlistSellerMedias,getSellerMedia,createSellerMediaandupdateSellerMedia. They also addpostOffer.maximumLinks,postOffer.advertisingLabellingPolicyandpostOffer.promotedSocialNetworks.updateSellerMediano longer requirespostOffer.pricingOptions, so a post offer update can change any of those fields alone. - AI keywords in a project. Each media of
searchProjectMediasaddsproject.aiKeywords, the AI keywords the web lists for the media in the project, each with itssource(googleAiOverview,chatgpt,googleAiMode, ornull). An entry does not mean the engine cited the media. - A partial update no longer erases what it leaves out. Java's
updateSellerMediasentpostOffer.promotedSocialNetworksandextraServicesas empty lists when they were not set, which cleared them, and itsupdateProjectsent emptytopics,promotedProductsanduserKeywords, so a partial project update was refused with a 422. In C#, every request sent each optional, nullable property left unset asnull, which the API reads as a value. A project rename alone set the country to worldwide, and a seller media update withoutLinkAdditionOfferstopped link additions. Both now send only what was set, and C# still sends anullyou assign (Country = null). - Java models start with null lists and maps. To fix the update above (
containerDefaultToNullinsdks.json), every list and map property in Java now starts asnullinstead of an empty container, in responses and in the models you build.Error.getDetails()returnsnullwhen an error carries no details, so check it before reading it; every other list in a response is required, so the API always sends it. On a model you build, theadd...Itemmethods create the list, and a required list left unset is left out of the request rather than sent as[]. - The request's sub-objects keep their 1.0.2 class names, which
createSellerMediareuses:UpdateSellerMediaRequestExtraServicesInner,UpdateSellerMediaRequestLinkAdditionOffer,UpdateSellerMediaRequestLinkAdditionOfferPricingOptionsInnerandUpdateSellerMediaRequestPostOfferPricingOptionsInner. See "Inline class names" under Regenerating.
Changes in 1.0.2
Four API classes are renamed, and code that imports the old names breaks, although the version is a patch. The contract now groups its operations by role (general, buyer, seller). Each generated class is still named after its tag, and the tags that were ambiguous now name the role. The operations, their methods and their models are unchanged; only the class that holds them moved:
| Before | 1.0.2 | Operations |
|---|---|---|
OrdersApi |
BuyerOrdersApi |
listOrders, getOrder, estimateOrder, createOrder, cancelOrder, approveOrder, approveOrderDraft, requestOrderUpdate, requestOrderDraftUpdate, downloadOrderBriefing, downloadOrderDraft |
BuyerMoneyApi |
BuyerWalletApi |
getBuyerWallet, listBuyerTransactions, downloadBuyerTransactionDocument, listInvoices, getInvoicePdf |
SellerMoneyApi |
SellerWalletApi |
getSellerWallet, listSellerTransactions, downloadSellerTransactionDocument, listWithdrawals, downloadWithdrawalInvoice |
AccountApi (one operation) |
SellerAccountApi (new) |
putSellerHoliday; the rest of AccountApi stays |
The names follow each language's casing: client.BuyerOrdersAPI in Go, Growwer::BuyerOrdersApi in
Ruby, BuyerOrdersApi everywhere else, with the Python module growwer.api.buyer_orders_api.
Also in this release:
- Python 3.10 or later is required, and the dependency floors rise for security fixes:
urllib3 >= 2.8.0, and for developmentpytest >= 9.0.3andfilelock >= 3.20.3. - Catalogue trial quota. An account without a recent completed order reads the catalogue within
a daily trial quota instead of
403 catalogue_requires_recent_order, andGET /meaddscatalogue.dailyCapandcatalogue.remainingToday.429 catalogue_daily_cap_reachedaddsdetails.limitanddetails.tier. See The catalogue. - Failed authentications are throttled per key and address, under a per-address ceiling, so one bad key no longer blocks the other keys from its address. See Rate limits.
- At most 20 pending uploads, past which
uploadFileanswers429 file_limit_reached. See Uploaded files. requestIdis documented as a UUID v4 generated per request, or theX-Request-Idyou sent.
Conventions
These hold in every language. The per-language docs cover the signatures; this is what the signatures cannot say.
Errors and request ids
Every response carries an X-Request-Id header, and every refusal answers the same envelope with
that id in requestId. Quote it when you report a problem and it identifies your exact request.
{
"code": "insufficient_balance",
"message": "The available balance of your wallet does not cover the price of this order. Nothing was charged. Add funds to your wallet and retry.",
"requestId": "0b8f6c2e-4a1d-4f3b-9c7e-5d2a8e1f6b34",
"details": {
"required": { "amount": 9000, "currency": "EUR", "amountDecimal": "90.00" },
"available": { "amount": 2500, "currency": "EUR", "amountDecimal": "25.00" },
"missing": { "amount": 6500, "currency": "EUR", "amountDecimal": "65.00" }
}
}
code is stable and machine-readable; branch on it. message is for people and may change.
details appears on some errors only — the invalid fields of a 422 validation_failed, the
shortfall of a 409 insufficient_balance — as each operation's responses describe.
In Python the common statuses raise their own subclass of ApiException: BadRequestException
(400), UnauthorizedException (401), ForbiddenException (403), NotFoundException (404),
ConflictException (409), UnprocessableEntityException (422) and ServiceException (5xx). They
live in growwer.exceptions, not at the top level of the growwer package, and data holds the
parsed envelope whenever the operation documents that status:
from growwer.exceptions import ApiException, BadRequestException try: ... except BadRequestException as error: print(error.data.code, error.data.message, error.data.request_id) except ApiException as error: print(error.status, error.headers.get("X-Request-Id"))
Header names are case-insensitive, so look them up that way. This matters in C#: .NET rewrites
the header names it knows, so X-Request-Id arrives as X-Request-ID, and the client's
Multimap of response headers compares names exactly:
catch (ApiException e) { var requestId = e.Headers .Where(h => string.Equals(h.Key, "X-Request-Id", StringComparison.OrdinalIgnoreCase)) .SelectMany(h => h.Value) .FirstOrDefault(); }
PHP's ApiException::getResponseHeaders() is a plain array keyed in the case the server sent, which
over HTTP/2 is lower case; compare names case-insensitively there too.
Rate limits
600 requests per user, across all of that user's keys, and 300 per key, in fixed windows of 60 seconds. The per-user ceiling is the real one: issuing more keys divides the same budget rather than adding to it.
Every response reports the tighter of the two windows in X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset (seconds until it refills). A request beyond either
answers 429 rate_limit_exceeded with a Retry-After header in seconds. Honour it:
import time from growwer.exceptions import ApiException def with_backoff(call, attempts=3): for attempt in range(attempts): try: return call() except ApiException as error: code = getattr(error.data, "code", None) if error.status != 429 or code != "rate_limit_exceeded" or attempt == attempts - 1: raise time.sleep(int(error.headers.get("Retry-After", "1")))
Retry only rate_limit_exceeded automatically. Other refusals answer 429 too, and retrying them
does not help: file_limit_reached waits for a file to expire, catalogue_daily_cap_reached and
managed_service_request_limit_reached wait for the next UTC day, and retrying too_many_failed_authentications with the same key only keeps it
refused. Branch on code for those.
Failed authentications are counted per address and presented key: 10 failures in 15 minutes
with one key from one address refuse that key from that address with
429 too_many_failed_authentications and a Retry-After header, so one bad key does not lock out
the other keys used from the same address. An address also has a ceiling of 300 failed
authentications in 15 minutes across all keys, past which every further failure from it answers
that 429 instead of 401. A valid key always passes. When the rate limiter itself cannot be reached,
the API refuses the call with 503 rate_limit_unavailable rather than serving it unmetered; retry
later.
Writes are idempotent
Every write requires an Idempotency-Key header: 1 to 255 printable ASCII characters without
spaces (! to ~); any other key answers 400 invalid_idempotency_key. A UUID is the obvious
choice. Use a new key for each operation and reuse it only to retry that same operation. Keys
belong to your account, not to one API key.
import uuid from growwer.api import ProjectsApi from growwer.models import CreateProjectRequest ProjectsApi(client).create_project( idempotency_key=str(uuid.uuid4()), create_project_request=CreateProjectRequest( root_url="https://www.example.com", website_type="ecommerce", topics=[3], language="es", promoted_products=["running shoes", "trail shoes", "socks", "insoles", "gaiters"], ), )
Generate the key outside your retry loop. A fresh key per attempt is the same as having no idempotency at all.
- A successful write is stored for 30 days. A retry with the same key and the same request
replays its response with
Idempotent-Replayed: trueand does nothing again. - A refused write (4xx) has no effect and releases its key. Fix the cause and retry with the same key: the write runs.
- A failed write (5xx) may have taken effect, so it is never run again under its key: a retry
answers
409 idempotency_outcome_unknown. Check whether it took effect before retrying with a new key. - A retry while the first request is still running answers
409 idempotency_request_in_progress. A request still running after 5 minutes is treated as failed: a retry answers409 idempotency_outcome_unknown. - The same key with a different request — method, path, query or body — answers
409 idempotency_key_reusedrather than the wrong operation. A write without the header answers400 idempotency_key_required.
Placing the same order twice
Independently of the idempotency key, placing the same order — same project, media, type and
price — again within 5 seconds of one still in progress is refused with 409 duplicate_order,
and nothing is charged. details.orderId is the earlier order, or null when that order has
finished or was deleted since.
Estimate an order first (estimateOrder) and send the price it answers as expectedPrice: the
order is placed only at that price.
Uploaded files
A file uploaded with uploadFile can be used by one write and expires 24 hours after its upload.
You may hold at most 20 pending files: uploaded, not yet used by a write and not expired.
Another upload past them answers 429 file_limit_reached, with details.limit the number you may
hold and Retry-After the seconds until the oldest of them expires. Use one of your pending files
in a write, which frees its place at once, or wait. A file refused as invalid (422) is refused
before the files are counted, and a refused upload stores nothing.
Paging
Listings take limit (default 20, maximum 100) and offset, and answer {items, total}. A limit
above the maximum is refused with 422 validation_failed, never quietly clamped, so the page
you asked for is the page you get. total counts everything the filter matches, not the size of
the page.
Following changes
Poll for changes. Call GET /orders (or GET /seller/orders for the orders on your medias) with
sort=updatedAt and updatedSince set to the updatedAt of the last order you saw: an order
moves to the end of that list each time it changes state. updatedAt changes on state changes
only.
Webhooks push the changes of the orders you place as they happen, but keep the poll as the fallback. It is the only way to follow:
- The orders on your medias: webhooks cover the orders you place, not
GET /seller/orders. - What a subscription missed: events while it was disabled, because its endpoint failed for 5
days (
failing) or for another reason, and deliveries older than 30 days, which are deleted and cannot be redelivered.
Webhooks
A webhook subscription has Growwer POST events to an HTTPS endpoint of yours as they happen:
order.created,order.accepted,order.rejected,order.published,order.update_requested,order.completedandorder.cancelled, for the orders you place.balance.low, when a debit takes your available buyer balance below the threshold you set.webhook.test, whichsendWebhookTestsends to check your endpoint.
A subscription receives the events of its key's mode. One created with a live key gets the
events of your orders and your wallet. One created with a test key gets those of your
test orders and test wallet, each with mode: "test", and never a live event.
Subscribe with createWebhook. lowBalanceThreshold is required with balance.low, and an
account holds at most 20 subscriptions per mode (409 webhook_limit_reached). The answer carries
the signing secret, whsec_ and a base64 key, once: store it then. A replay under the same
Idempotency-Key answers the subscription without it. rotateWebhookSecret issues a new secret;
for 24 hours, until previousSecretExpiresAt, both sign every delivery, so you can switch without
missing an event.
import uuid from growwer.api import WebhooksApi from growwer.models import CreateWebhookRequest, Money webhook = WebhooksApi(client).create_webhook( idempotency_key=str(uuid.uuid4()), create_webhook_request=CreateWebhookRequest( url="https://hooks.example.com/growwer", events=["order.published", "order.completed", "balance.low"], low_balance_threshold=Money(amount=5000, currency="EUR"), ), ) save_secret(webhook.id, webhook.secret)
In TypeScript and Java, events is a Set.
Receive each event this way:
- Verify the signature over the raw body, exactly as received, before you parse it. Each
delivery carries the Standard Webhooks headers
webhook-id,webhook-timestampandwebhook-signature. Answer a delivery that fails the check with a 4xx and do not act on it. - Answer 2xx within 15 seconds, and do the work afterwards, from a queue for instance.
Anything else, a redirect or a timeout included, is retried with jitter: up to 10 attempts
within 72 hours of the first, honouring
Retry-Afteron 429 and 503. An endpoint answering410disables the subscription (endpoint_gone). So do 5 days of failures (failing), and the account owner is emailed.updateWebhookwithenabled: trueenables it again. - Dedupe by
webhook-id. It is the event'sid, the same on every retry and redelivery, and events are delivered at least once. - Order by
createdAt, since events arrive in no guaranteed order. For an order, compare itsupdatedAtwith the last one you handled.
listWebhookDeliveries shows the last 30 days of deliveries with their attempts, and
redeliverWebhook sends one again with the same webhook-id.
Verify with the Standard Webhooks library for your language. Each one takes the whsec_
secret as createWebhook answers it, accepts any of the signatures in webhook-signature, and
refuses a webhook-timestamp more than 5 minutes from now. Growwer ships no verifier of its own.
| Language | Package | Install |
|---|---|---|
| Python | standardwebhooks |
pip install standardwebhooks |
| TypeScript | standardwebhooks |
npm install standardwebhooks |
| PHP | standard-webhooks/standard-webhooks |
composer require standard-webhooks/standard-webhooks |
| Go | github.com/standard-webhooks/standard-webhooks/libraries |
go get github.com/standard-webhooks/standard-webhooks/libraries |
| Java | com.standardwebhooks:standardwebhooks |
Maven or Gradle |
| .NET | StandardWebhooks.StandardWebhooks |
dotnet add package StandardWebhooks.StandardWebhooks |
| Ruby | standardwebhooks |
gem install standardwebhooks |
On NuGet, the package named StandardWebhooks alone is a different, third-party one.
Then parse the body into WebhookEvent. Its type names the event, its mode the mode of
the subscription, and its data is one of three models:
OrderEventDatafor theorder.*events:order, the order asGET /orders/{id}answers it at the time of the event.BalanceLowEventDataforbalance.low:wallet, the buyer wallet after the debit, andthreshold.WebhookTestEventDataforwebhook.test:webhookId, the subscription it was sent to.
secret below is the whsec_ secret you stored, and body the raw request body.
Python
from standardwebhooks.webhooks import Webhook, WebhookVerificationError from growwer.models import BalanceLowEventData, OrderEventData, WebhookEvent verifier = Webhook(secret) def receive(body: bytes, headers) -> int: try: verifier.verify(body, headers) # any mapping of the request headers except WebhookVerificationError: return 400 event = WebhookEvent.from_json(body.decode()) data = event.data.actual_instance if isinstance(data, OrderEventData): handle_order(event.id, data.order) elif isinstance(data, BalanceLowEventData): handle_low_balance(event.id, data.wallet.available_balance) return 204
TypeScript
import { Webhook } from "standardwebhooks" import { WebhookEventFromJSON, type BalanceLowEventData, type OrderEventData } from "@growwer/api" const verifier = new Webhook(secret) // headers: the request headers as a plain object of strings function receive(body: string, headers: Record<string, string>): number { let payload: unknown try { payload = verifier.verify(body, headers) // answers the parsed JSON } catch { return 400 } const event = WebhookEventFromJSON(payload) if (event.type === "balance.low") { handleLowBalance(event.id, (event.data as BalanceLowEventData).wallet.availableBalance) } else if (event.type.startsWith("order.")) { handleOrder(event.id, (event.data as OrderEventData).order) } return 204 }
data is a union of the three models: narrow it by type.
PHP
use Growwer\Api\Model\WebhookEvent; use Growwer\Api\ObjectSerializer; use StandardWebhooks\Exception\WebhookVerificationException; use StandardWebhooks\Webhook; $body = file_get_contents('php://input'); try { // The library reads these three lower-case keys. (new Webhook($secret))->verify($body, [ 'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? null, 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null, 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null, ]); } catch (WebhookVerificationException $exception) { http_response_code(400); exit; } /** @var WebhookEvent $event */ $event = ObjectSerializer::deserialize(json_decode($body), WebhookEvent::class); $data = $event->getData(); if ($event->getType() === 'balance.low') { handleLowBalance($event->getId(), $data->getWallet()->getAvailableBalance()); } elseif ($data->getOrder() !== null) { handleOrder($event->getId(), $data->getOrder()); } http_response_code(204);
data is one WebhookEventData with the fields of all three models; those of the other two are
null.
Go
import ( "encoding/json" "io" "log" "net/http" standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go" growwer "github.com/viralitymedia/growwer-api-sdk/go/v2" ) verifier, err := standardwebhooks.NewWebhook(secret) if err != nil { log.Fatal(err) } http.HandleFunc("/growwer", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil || verifier.Verify(body, r.Header) != nil { w.WriteHeader(http.StatusBadRequest) return } var event growwer.WebhookEvent if err := json.Unmarshal(body, &event); err != nil { w.WriteHeader(http.StatusBadRequest) return } switch { case event.Data.OrderEventData != nil: handleOrder(event.Id, event.Data.OrderEventData.Order) case event.Data.BalanceLowEventData != nil: handleLowBalance(event.Id, event.Data.BalanceLowEventData.Wallet.AvailableBalance) } w.WriteHeader(http.StatusNoContent) })
Exactly one of event.Data's pointers is set.
Java
import com.growwer.api.model.BalanceLowEventData; import com.growwer.api.model.OrderEventData; import com.growwer.api.model.WebhookEvent; import com.standardwebhooks.Webhook; import com.standardwebhooks.exceptions.WebhookVerificationException; Webhook verifier = new Webhook(secret); // headers: a Map<String, List<String>> of the request headers, or java.net.http.HttpHeaders try { verifier.verify(body, headers); } catch (WebhookVerificationException e) { return 400; } WebhookEvent event = WebhookEvent.fromJson(body); Object data = event.getData().getActualInstance(); if (data instanceof OrderEventData) { handleOrder(event.getId(), ((OrderEventData) data).getOrder()); } else if (data instanceof BalanceLowEventData) { handleLowBalance(event.getId(), ((BalanceLowEventData) data).getWallet().getAvailableBalance()); } return 204;
C#
using System.Net; using Growwer.Api.Model; using Newtonsoft.Json; using StandardWebhooks.Exceptions; // Qualified, since Growwer.Api.Model has a Webhook model too. var verifier = new StandardWebhooks.Webhook(secret); // Copy the three headers from your framework's request. var headers = new WebHeaderCollection { { "webhook-id", request.Headers["webhook-id"] }, { "webhook-timestamp", request.Headers["webhook-timestamp"] }, { "webhook-signature", request.Headers["webhook-signature"] }, }; try { verifier.Verify(body, headers); } catch (WebhookVerificationException) { return 400; } var webhookEvent = JsonConvert.DeserializeObject<WebhookEvent>(body); switch (webhookEvent.Data.ActualInstance) { case OrderEventData data: HandleOrder(webhookEvent.Id, data.Order); break; case BalanceLowEventData data: HandleLowBalance(webhookEvent.Id, data.Wallet.AvailableBalance); break; } return 204;
Ruby
require "growwer" require "json" require "standardwebhooks" verifier = StandardWebhooks::Webhook.new(secret) begin # The library reads these three lower-case keys; this is Rack's request. verifier.verify(body, { "webhook-id" => request.get_header("HTTP_WEBHOOK_ID"), "webhook-timestamp" => request.get_header("HTTP_WEBHOOK_TIMESTAMP"), "webhook-signature" => request.get_header("HTTP_WEBHOOK_SIGNATURE"), }) rescue StandardWebhooks::WebhookVerificationError return 400 end event = Growwer::WebhookEvent.build_from_hash(JSON.parse(body)) case event.data when Growwer::OrderEventData then handle_order(event.id, event.data.order) when Growwer::BalanceLowEventData then handle_low_balance(event.id, event.data.wallet.available_balance) end
What the libraries check, should you verify by hand: base64-decode the secret after whsec_
to get the key, and compute the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body> with it.
webhook-signature holds space-separated v1,<base64> entries. Accept the delivery when the
base64 of your HMAC equals one of them, compared in constant time, and refuse a
webhook-timestamp (Unix seconds) more than 5 minutes from your clock.
New event types. The enum limitation applies to webhooks twice.
WebhookEvent.type: the Python, PHP, Java, C# and Ruby clients refuse an event type they were not
generated with. A subscription gets only the events it lists, plus webhook.test, so list a new
event only once your client is on a release that knows it. Webhook.events, the events a
subscription lists: every client except TypeScript, Go included, refuses a subscription that lists
an event it was not generated with. Once such an event is subscribed anywhere on your account, by
the MCP tool or a newer client for example, a 2.1.0 client fails listWebhooks, getWebhook,
updateWebhook and rotateWebhookSecret for it. Upgrade every client that manages your
subscriptions before anything subscribes to a new event.
Test mode
A test key (gw_sk_test_ and 43 characters, created on the same API keys page) works on your
account's test data alone: test projects, orders and files, and a test wallet that starts at
1,000.00 EUR. It reads the real catalogue, and nothing it does reaches real sellers, money or
orders. It is told apart in three ways:
getMeanswersmode: "test"for a test key and"live"for a live one, andseller: null, since selling is not available in test mode.- Every response to a test key carries the header
Growwer-Mode: test. - An id of the other mode answers
404, as a missing object would.
A test order is validated and debited from the test wallet as a live one is. A simulated seller
accepts it a minute later and delivers it two minutes after that; you approve the delivery or
request an update. Idempotency-Keys are scoped per mode: the same key sent with a live and a
test key never replays the other mode's response.
TestHelpersApi serves test keys only; a live key on it answers 403 test_mode_only:
topUpTestWalletcredits the test wallet, up to 10,000.00 EUR per call.advanceTestOrdermoves a test order on at once, without waiting for the simulated seller.resetTestDatadeletes your test projects, orders and files and returns the test wallet to 1,000.00 EUR. Your API keys and webhook subscriptions stay.
A test key answers 403 not_available_in_test_mode on the seller and managed service operations,
on a project's catalogue writes and on media requests. It can subscribe to webhooks: its
subscriptions get the events of its test orders and test wallet.
Money and dates
Money is an object of integer cents and a currency code — {"amount": 9000, "currency": "EUR"}
is 90.00 EUR — never a float. Every Money the API answers also carries amountDecimal, the same
amount as an exact decimal string in major units: "90.00".
A Money you send may leave amountDecimal out or carry it exactly as it was answered; any other
value is refused with 422 validation_failed. The Python, TypeScript and C# clients never send it.
The Go, Java, PHP and Ruby clients send the one a Money was read with, so a Money read from a
response can be sent back as it is, but changing its amount and sending it is refused: build a
new Money from amount and currency instead.
Dates and date-times are ISO-8601. A date-time sent in a query string carries its offset, and its
+ must be sent as %2B.
The catalogue
The catalogue operations (searching a project's medias, flash offers, media details) show each
account a limited number of distinct medias per UTC day; medias seen again the same day are free.
Accounts with enough recent orders delivered and approved, as buyer or as seller, get the full
daily cap of 5,000. Other accounts get a trial quota of 100 distinct medias per UTC day
instead of a refusal, and it rises to the full cap once they have those orders. An order counts
only once it is delivered and approved, so one placed today usually does not lift today's quota. A
media you have ordered is always readable with GET /medias/{id} and counts against neither.
GET /me reports where you stand:
catalogue.eligible: whether you have the full cap.catalogue.dailyCap: the distinct medias you may see per UTC day, the full cap or the trial quota.catalogue.remainingToday: how many new medias still fit today, or null when the count cannot be read right now; the catalogue operations then answer 503 until it can.
A response that would go beyond the cap answers 429 catalogue_daily_cap_reached:
details.remaining says how many new medias still fit today, details.limit is the daily cap that
applied and details.tier is full or trial; Retry-After is the time to the next UTC midnight,
and medias already seen today stay readable. A trial refusal's message names the threshold of
orders, which details.minOrders (how many orders) and details.windowDays (within how many days)
repeat. 403 catalogue_requires_recent_order is left only for when the trial quota is turned off
(catalogue.dailyCap is 0) and the account has too few recent orders delivered and approved; it
names the same threshold in its message, details.minOrders and details.windowDays.
The managed service requests are capped per UTC day too, at 5 of each kind:
429 managed_service_request_limit_reached, with Retry-After the time to the next UTC midnight.
Forward compatibility
The API adds fields to its responses without a new version. Every client here ignores a response
field it does not know, so a client generated today keeps reading tomorrow's responses. The
offline fixture tests under tests/ prove it for each language on every change.
Known limitation: enum values. A new value in an existing enum field is not tolerated
everywhere. The Python, PHP, Java, C# and Ruby clients refuse a value they were not generated with,
and the whole response fails to deserialise. TypeScript passes a new value through. Go holds enum
fields as plain strings and passes a new value through, except WebhookEventType (in
Webhook.events), the one enum with a Go type of its own, which checks its value on unmarshal and
errors on an unknown one. The generator's enumUnknownDefaultCase option would map unknown
values to a sentinel instead; it is not enabled. Upgrade to the release that follows an enum change
before the API starts sending the new value to you.
Regenerating
./generate.sh # every SDK, from the committed openapi.json ./generate.sh python go # only these ./generate.sh --sync # fetch the live contract first, then regenerate everything
Requires Docker and Python 3. Everything inside each language directory is generated and must not
be edited by hand — the next run discards it. Adding a language is one entry in
sdks.json.
--sync reads the contract from contractUrl in sdks.json, or from
GROWWER_CONTRACT_URL when it is set. The URL goes to curl, so a contract dumped to disk is
synced with a file URL:
GROWWER_CONTRACT_URL=file:///absolute/path/openapi.json ./generate.sh --sync
The fetch is opt-in because a regeneration that silently pulls a new contract turns a mechanical rerun into a content change nobody reviewed, and the diff runs to six figures of lines across seven languages — the one place a surprise must not hide. A fetched contract with fewer than 60 operations is refused rather than written: it is far more likely to be a misrouted response than a real change.
--sync writes the contract in the layout of the committed openapi.json — two-space indent, UTF-8
left unescaped, a trailing newline — so the diff it leaves is only what the contract changed. It is
written with Python's json module, which would differ from jq in two cases the contract does not
contain: U+007F (DEL) is written raw where jq escapes it, and a number literal is rewritten in
Python's form (1e3 becomes 1000.0, 1.50 becomes 1.5).
The generator never deletes a file it no longer emits, so after each language is generated the
script deletes every file the previous .openapi-generator/FILES listed and the new one does not.
Three kinds of listed path are kept, because their absence from the new list does not mean the
contract dropped them:
- Test stubs, except Go's. The generator writes a stub only when it is missing and lists it only
on the run that writes it, so a stub added in one run is gone from the next run's list. Nothing
under the directory named by the SDK's
testStubDirinsdks.jsonis deleted (python/test/,php/test/,java/src/test/,csharp/src/Growwer.Api.Test/,ruby/spec/), so when the contract drops a schema or operation its stub has to be deleted by hand. Go is generated into an empty directory, so its stubs are written and listed on every run and are removed with the rest; TypeScript has none. For the same reason, after a contract change that adds a schema or operation, run the script twice before committing: the first run lists the new stubs in.openapi-generator/FILESand the second drops them again, which is the list CI expects. .openapi-generator-ignore, which the generator likewise writes only when it is missing.- Any path the target's
.openapi-generator-ignorenames: the generator stopped writing it because it is now maintained by hand. Go's empty output directory gets a copy ofgo/.openapi-generator-ignorebefore each run, so the file works there as it does elsewhere.
These corrections are applied by the generator script rather than by hand, because a hand edit would be discarded on the next run:
- Go has no package registry, so the import path is the repository URL plus the directory, and
from 2.0.0 on the
/v2Go requires of a second major. The generator derives the module path from the Go package name, andgois a keyword, so it cannot produce.../growwer-api-sdk/go/v2on its own. - Go's oneOf template writes its no-match branch twice in a row, and
go vetrejects the second copy as unreachable code. The copy is dropped byscripts/go-oneof-unreachable.py. - C#'s model template makes an enum property nullable only when it is optional, ignoring
nullable: true, so the null the contract allows in a required enum such asOrder.pendingActionwould fail deserialisation. The?is added byscripts/csharp-nullable-enums.py. - C#'s model template also writes an optional, nullable property whenever it is null, so
every request would send each one left unset as
null, which the API reads as a value, and an optional boolean likewise, nullable or not, so aboolleft unset would be sent asfalse. Each optional property of a request body that is nullable or a boolean, read from the contract, gets a flag that its setter raises and aShouldSerializemethod that Newtonsoft.Json consults; a constructor argument raises the flag only when it is not null, and the constructor parameter of a non-nullable boolean is abool?so that it can be left out. This is done byscripts/csharp-optional-nullables.py. The generator'sconditionalSerializationoption would do the same, but it never marks a required constructor argument as set, so it would drop every required property. - Inline class names. The generator gives identical inline schemas one class, named after the
first operation that uses it, so an operation added before one that already had the shape would
rename its class and break every client that names it.
inlineSchemaNameMappingsinsdks.jsonpins the published name, keyed by the generator's operation-based name (createSellerMedia_request_linkAdditionOffer), and is passed as--inline-schema-name-mappings. - Package metadata. Left alone, the Python, TypeScript and PHP manifests name OpenAPI Generator
as their author and carry its description and keywords. They are rewritten from
sdks.jsonbyscripts/package-metadata.py, which also sets the PHP package name, declares the TypeScript package's Node floor and raises the Python dependency floors (Python, urllib3, pytest and filelock) past the releases with open advisories, since Dependabot cannot update a project with neither a lockfile nor a pinned version.
The template fixes fail the run when they find nothing left to correct, so a generator upgrade that makes one obsolete removes it rather than leaving a silent no-op.
Contract
openapi.json— the contract every client here is generated from- Live contract and browsable reference
Releasing
A vX.Y.Z tag runs release.yml. It checks that the tag equals
packageVersion in sdks.json and that regenerating leaves no diff, then publishes to
npm, PyPI, Maven Central, NuGet and RubyGems, and pushes the go/vX.Y.Z tag the Go module proxy
resolves. Packagist picks the tag up through its webhook.
No release happens before the committed contract matches production. Before each tag,
./generate.sh --sync against production must leave no diff; release.yml only checks the
committed clients against the committed contract.
An owner sets the registries up once, before the first tag:
- npm: the
growwerorganisation, and an automation token with publish rights on@growwer/apistored as the repository secretNPM_TOKEN. - PyPI: a trusted publisher for the
growwerproject naming this repository, the workflowrelease.ymland the environmentrelease. - NuGet: a trusted publishing policy for
Growwer.Apinaming this repository,release.ymland the environmentrelease, and the nuget.org account it was created under stored as the repository variableNUGET_USER(a variable, not a secret). - RubyGems: a push key for the
growwergem stored as the repository secretRUBYGEMS_API_KEY. - Maven Central: the Sonatype Central namespace
com.growwer, verified with a DNS TXT record on growwer.com, and the repository secretsMAVEN_CENTRAL_USERNAME,MAVEN_CENTRAL_PASSWORD,MAVEN_GPG_PRIVATE_KEYandMAVEN_GPG_PASSPHRASE. - Packagist: submit this repository as
growwer/growwer-api-sdk, and add Packagist's GitHub webhook so new tags are picked up. - Go: nothing. The module proxy resolves the
go/vX.Y.Ztag the workflow pushes. A new major also movesGO_MODULEingenerate.shto its/vN, or that tag does not resolve. - The GitHub environment
release, which every publishing job runs in.
License
MIT. See LICENSE.