Parâmetros
- SHOPENTLY_SHOP_URL — URL do recurso.
- SHOPENTLY_ACCESS_TOKEN — Credencial Bearer de curta duração.
- STOCK_ID — Identificador do registo de stock.
- LOCATION_ID — Identificador da localização no armazém.
- NEW_ON_HAND — Quantidade física em stock.
- ADJUSTMENT_REASON — Motivo registado para a alteração.
- IDEMPOTENCY_KEY — Identificador único de uma operação lógica de escrita.
PHP 8.3 (64-bit, ext-curl, JSON) · Python 3.12 + requests · cURL 8 + jq 1.7.
Exemplo completo
cURL
#!/bin/sh
# POSIX shell, curl 8+, jq 1.7. Credentials stay in environment variables.
set -eu
: "${SHOPENTLY_SHOP_URL:?}"
base=${SHOPENTLY_SHOP_URL%/}
case "$base" in https://*) ;; *) echo 'Use an HTTPS origin' >&2; exit 1;; esac
authority=${base#https://}
case "$authority" in ''|*/*|*\?*|*\#*|*@*) echo 'Use an HTTPS origin' >&2; exit 1;; esac
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
chmod 700 "$work"
encode() { printf '%s' "$1" | jq -sRr @uri; }
api() {
method=$1; path=$2; body=${3-}; key=${4-}
scheme=Bearer; credential=${SHOPENTLY_ACCESS_TOKEN-}
if [ "$path" = /api/v1/auth/token ]; then scheme=ApiKey; credential=${SHOPENTLY_API_KEY-}; fi
[ -n "$credential" ] || { echo 'Missing credential' >&2; return 1; }
set -- --disable --globoff --silent --show-error --proto '=https' --connect-timeout 5 --max-time 30 \
--request "$method" --header "Authorization: $scheme $credential" \
--dump-header "$work/headers" --output "$work/body" --write-out '%{http_code}'
if [ -n "$key" ]; then set -- "$@" --header "Idempotency-Key: $key"; fi
if [ -n "$body" ]; then set -- "$@" --header 'Content-Type: application/json' --data-raw "$body"; fi
status=$(curl "$@" --url "$base$path") || return 1
case "$status" in
2??) if [ -s "$work/body" ]; then jq -e . "$work/body"; else printf 'null\n'; fi ;;
*) printf 'HTTP %s\n' "$status" >&2
jq -c '.error | {code, fields}' "$work/body" >&2 2>/dev/null || true
return 1 ;;
esac
}
: "${STOCK_ID:?}" "${LOCATION_ID:?}" "${NEW_ON_HAND:?}" "${ADJUSTMENT_REASON:?}" "${IDEMPOTENCY_KEY:?}"
case "$NEW_ON_HAND" in ''|*[!0-9]*) echo 'Use a nonnegative integer' >&2; exit 1;; esac
path="/api/v1/inventory/stock/$(encode "$STOCK_ID")"
stock=$(api GET "$path")
printf '%s' "$stock" | jq -e --arg id "$LOCATION_ID" 'any(.data.allocations[]; .location_id == $id)' >/dev/null
body=$(printf '%s' "$stock" | jq -c --arg id "$LOCATION_ID" --argjson quantity "$NEW_ON_HAND" --arg reason "$ADJUSTMENT_REASON" '{
expected_version: .data.version, reason: $reason, locations: [.data.allocations[] |
{location_id, on_hand: (if .location_id == $id then $quantity else .on_hand end)}]}')
api PATCH "$path" "$body" "$IDEMPOTENCY_KEY"
PHP
<?php
declare(strict_types=1);
// PHP 8.3+, 64-bit, ext-curl. Credentials stay in environment variables.
function env(string $name): string {
$value = getenv($name);
if ($value === false || $value === '' || strpbrk($value, "\r\n") !== false) {
throw new RuntimeException('Invalid environment variable: ' . $name);
}
return $value;
}
$base = rtrim(env('SHOPENTLY_SHOP_URL'), '/');
$origin = parse_url($base);
if (!$origin || ($origin['scheme'] ?? '') !== 'https' || empty($origin['host']) ||
isset($origin['user']) || isset($origin['pass']) || isset($origin['path']) ||
isset($origin['query']) || isset($origin['fragment']) || PHP_INT_SIZE !== 8) {
throw new RuntimeException('Use a 64-bit PHP runtime and an HTTPS origin');
}
class ApiError extends RuntimeException {
public function __construct(public int $status, public array $headers, public array $error) {
parent::__construct('HTTP ' . $status . '; code=' . ($error['code'] ?? '') .
'; request_id=' . ($headers['x-request-id'] ?? ''));
}
}
function api(string $method, string $path, array $query = [], ?array $body = null, ?string $key = null, bool $apiKey = false): ?array {
global $base;
$credential = env($apiKey ? 'SHOPENTLY_API_KEY' : 'SHOPENTLY_ACCESS_TOKEN');
$headers = ['Authorization: ' . ($apiKey ? 'ApiKey ' : 'Bearer ') . $credential];
if ($key !== null) $headers[] = 'Idempotency-Key: ' . $key;
if ($body !== null) $headers[] = 'Content-Type: application/json';
$url = $base . $path . ($query ? '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) : '');
$responseHeaders = [];
$h = curl_init($url);
curl_setopt_array($h, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => static function ($h, string $line) use (&$responseHeaders): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
if ($body !== null) {
$json = $apiKey && $body === [] ? '{}' : json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
curl_setopt($h, CURLOPT_POSTFIELDS, $json);
}
$raw = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$error = curl_error($h);
curl_close($h);
if ($raw === false) throw new RuntimeException('Transport error: ' . $error);
if ($status < 200 || $status >= 300) {
$data = json_decode($raw, true);
throw new ApiError($status, $responseHeaders, is_array($data) ? ($data['error'] ?? []) : []);
}
return $raw === '' ? null : json_decode($raw, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
}
function output(mixed $value): void {
echo json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;
}
$path = '/api/v1/inventory/stock/' . rawurlencode(env('STOCK_ID'));
$stock = api('GET', $path)['data'];
$locationId = env('LOCATION_ID');
if (!in_array($locationId, array_column($stock['allocations'], 'location_id'), true)) throw new RuntimeException('Choose an assigned location');
$quantity = filter_var(env('NEW_ON_HAND'), FILTER_VALIDATE_INT);
if ($quantity === false || $quantity < 0) throw new RuntimeException('Use a nonnegative integer');
$body = ['expected_version' => $stock['version'], 'reason' => env('ADJUSTMENT_REASON'),
'locations' => array_map(fn($a) => ['location_id' => $a['location_id'],
'on_hand' => $a['location_id'] === $locationId ? $quantity : $a['on_hand']], $stock['allocations'])];
output(api('PATCH', $path, body: $body, key: env('IDEMPOTENCY_KEY')));
Python
"""Run with Python 3.12 and requests. Credentials stay in environment variables."""
import json
import os
import random
import time
from urllib.parse import quote, urlencode, urlsplit
import requests
def env(name):
value = os.environ[name]
if not value or "\r" in value or "\n" in value:
raise ValueError("Invalid environment variable: " + name)
return value
base = env("SHOPENTLY_SHOP_URL").rstrip("/")
origin = urlsplit(base)
if origin.scheme != "https" or not origin.hostname or origin.username or origin.password or origin.path or origin.query or origin.fragment:
raise ValueError("SHOPENTLY_SHOP_URL must be an HTTPS origin")
class ApiError(RuntimeError):
def __init__(self, response):
self.status = response.status_code
self.request_id = response.headers.get("X-Request-ID", "")
self.retry_after = response.headers.get("Retry-After", "")
try:
self.error = response.json().get("error", {})
except (ValueError, AttributeError):
self.error = {}
super().__init__(f"HTTP {self.status}; code={self.error.get('code', '')}; request_id={self.request_id}")
def api(method, path, *, query=None, body=None, key=None, api_key=False):
credential = env("SHOPENTLY_API_KEY" if api_key else "SHOPENTLY_ACCESS_TOKEN")
headers = {"Authorization": ("ApiKey " if api_key else "Bearer ") + credential}
if key:
headers["Idempotency-Key"] = key
if body is not None:
headers["Content-Type"] = "application/json"
response = requests.request(method, base + path, params=query, headers=headers,
data=json.dumps(body, ensure_ascii=False, allow_nan=False).encode() if body is not None else None,
timeout=(5, 30), allow_redirects=False)
if not 200 <= response.status_code < 300:
raise ApiError(response)
return response.json() if response.content else None
def segment(value):
return quote(value, safe="")
path = "/api/v1/inventory/stock/" + segment(env("STOCK_ID"))
stock = api("GET", path)["data"]
location_id = env("LOCATION_ID")
if location_id not in {a["location_id"] for a in stock["allocations"]}:
raise ValueError("Choose a location already assigned to this stock record")
quantity = int(env("NEW_ON_HAND"))
if quantity < 0:
raise ValueError("NEW_ON_HAND must be nonnegative")
body = {"expected_version": stock["version"], "reason": env("ADJUSTMENT_REASON"),
"locations": [{"location_id": a["location_id"],
"on_hand": quantity if a["location_id"] == location_id else a["on_hand"]}
for a in stock["allocations"]]}
print(json.dumps(api("PATCH", path, body=body, key=env("IDEMPOTENCY_KEY"))))
Como funciona
Os registos de stock distinguem a quantidade física on_hand, as reservas de pedidos, as reservas manuais e a quantidade disponível para venda. Use o identificador e a versão de stock retornados; stocks de produtos e variantes são destinos separados.
Um sellable_limit de -1 significa ilimitado, enquanto 0 significa indisponibilidade para venda. A disponibilidade ilimitada é representada por null em available. Não interprete null como zero. Ajustes de stock, limites de venda e reservas manuais usam operações e permissões separadas.