매개변수
- SHOPENTLY_SHOP_URL — 리소스 URL입니다.
- SHOPENTLY_ACCESS_TOKEN — 유효 기간이 짧은 Bearer 인증 정보입니다.
- CREATED_FROM — 생성 시각 필터의 시작입니다.
- CREATED_TO — 생성 시각 필터의 종료입니다.
PHP 8.3 (64-bit, ext-curl, JSON) · Python 3.12 + requests · cURL 8 + jq 1.7.
전체 예제
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
if [ "$method" = GET ]; then set -- "$@" --retry 2 --retry-max-time 60; 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
}
query='limit=100'
: "${CREATED_FROM:?}" "${CREATED_TO:?}"
query="$query&created_from=$(encode "$CREATED_FROM")&created_to=$(encode "$CREATED_TO")"
seen='[]'
while :; do
page=$(api GET "/api/v1/orders?$query")
printf '%s' "$page" | jq -r '.data[].id | @uri' > "$work/order-ids"
while IFS= read -r order_id; do
detail=$(api GET "/api/v1/orders/$order_id")
printf '%s' "$detail" | jq -c '.data'
done < "$work/order-ids"
more=$(printf '%s' "$page" | jq -r '.meta.pagination.has_more')
[ "$more" = true ] || break
cursor=$(printf '%s' "$page" | jq -er '.meta.pagination.next_cursor')
if printf '%s' "$seen" | jq -e --arg cursor "$cursor" 'index($cursor) != null' >/dev/null; then
echo 'Repeated cursor' >&2; exit 1
fi
seen=$(printf '%s' "$seen" | jq -c --arg cursor "$cursor" '. + [$cursor]')
query="limit=100&cursor=$(encode "$cursor")"
query="$query&created_from=$(encode "$CREATED_FROM")&created_to=$(encode "$CREATED_TO")"
done
PHP
<?php
declare(strict_types=1);
set_exception_handler(static function (Throwable $error): void {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(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;
}
function readOrderApi(string $path, array $query = []): array {
$deadline = hrtime(true) / 1e9 + 60;
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
return api('GET', $path, $query);
} catch (ApiError $error) {
if ($attempt === 2 || !in_array($error->status, [408, 429, 500, 502, 503, 504], true)) throw $error;
$delay = 2 ** $attempt;
$retry = $error->headers['retry-after'] ?? '';
if ($retry !== '') {
if (preg_match('/^[0-9]+$/D', $retry) === 1) $delay = (float)$retry;
elseif (($date = strtotime($retry)) !== false) $delay = max(0, $date - time());
}
if ($delay > $deadline - hrtime(true) / 1e9) throw $error;
usleep((int)($delay * 1000000));
}
}
throw new RuntimeException('GET retry limit reached');
}
$query = ['limit' => 100, 'created_from' => env('CREATED_FROM'), 'created_to' => env('CREATED_TO')];
$seen = [];
do {
$page = readOrderApi('/api/v1/orders', $query);
foreach ($page['data'] as $record) {
$detail = readOrderApi('/api/v1/orders/' . rawurlencode($record['id']));
output($detail['data']);
}
$pagination = $page['meta']['pagination'];
if (!$pagination['has_more']) break;
$cursor = $pagination['next_cursor'];
if (!$cursor || isset($seen[$cursor])) throw new RuntimeException('Missing or repeated cursor');
$seen[$cursor] = true;
$query['cursor'] = $cursor;
} while (true);
Python
"""Run with Python 3.12 and requests. Credentials stay in environment variables."""
import json
import os
import time
from urllib.parse import quote, urlsplit
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
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="")
def read(path, query=None):
deadline = time.monotonic() + 60
for attempt in range(3):
try:
return api("GET", path, query=query)
except ApiError as error:
if attempt == 2 or error.status not in {408, 429, 500, 502, 503, 504}:
raise
delay = 2 ** attempt
if error.retry_after:
try:
delay = int(error.retry_after)
except ValueError:
try:
delay = (parsedate_to_datetime(error.retry_after) - datetime.now(timezone.utc)).total_seconds()
except (ValueError, TypeError):
pass
delay = max(0, delay)
if delay > deadline - time.monotonic():
raise
time.sleep(delay)
query = {"limit": 100}
query.update(created_from=env("CREATED_FROM"), created_to=env("CREATED_TO"))
seen = set()
while True:
page = read("/api/v1/orders", query)
for record in page["data"]:
detail = read("/api/v1/orders/" + segment(record["id"]))
print(json.dumps(detail["data"], ensure_ascii=False, allow_nan=False))
pagination = page["meta"]["pagination"]
if not pagination["has_more"]:
break
cursor = pagination["next_cursor"]
if not cursor or cursor in seen:
raise RuntimeError("Missing or repeated cursor")
seen.add(cursor)
query["cursor"] = cursor
작동 방식
주문 목록과 상세 정보에는 delivery.dates가 포함됩니다. YYYY-MM-DD 날짜는 예정이며 실제 발송을 확인하지 않습니다. 현재 날짜가 우선하며 이전 주문은 저장된 예상치를 사용합니다. 삭제되거나 알 수 없는 값은 null입니다. 상세 정보에는 모든 주문 항목과 상품 이미지도 포함됩니다.
유효한 상태 식별자는 /api/v1/order-statuses 에서 가져오세요. 주문 상태와 결제 상태는 별도의 PATCH 엔드포인트와 권한을 사용합니다. 기록된 결제 상태를 변경해도 카드 결제나 결제 서비스 제공자를 통한 환불은 실행되지 않습니다.
delivery.dates: planned_dispatch_on, dispatch_timezone, estimated_delivery_from, estimated_delivery_to. 발송일 및 예상 배송일: YYYY-MM-DD; null. 발송 시간대: IANA.
주문 · 합계
금액과 세율은 항목 수정 사항을 포함한 저장된 주문에서 가져옵니다. 과거 데이터가 없으면 null이며 현재 카탈로그 세율로 채우지 않습니다. 금액은 십진수 문자열로 유지합니다.
- tax_country_code · 과세 국가 · 저장된 주문 데이터 · ISO 3166-1 alpha-2; null.
- lines[].unit_* · 한 단위 · 할인 전; lines[].original_total_* · 할인 전; lines[].total_* · 할인 후.
- lines[].discount_* · 할인; manual_discount · 수동 할인; percentage "10" = 10%.
- delivery.net / tax / gross · 할인 후; delivery.original_* · 할인 전; delivery.discount_* · 할인.
- tax_rate: "0.23" = 23%; "0" ≠ null. 알 수 없음: null; 확인된 0: 0.
- totals · 합계; tax_breakdown.groups · 세금 그룹; 미배분 세액 = order.tax − Σ groups.tax; ±.
목록은 요약을 반환합니다. 각 결과에 대해 GET /api/v1/orders/{order_id}를 조회하여 전체 금액 명세를 내보내세요. GET 재시도는 제한되며 Retry-After를 따릅니다. 생성일 기준 내보내기는 이후 수정 사항을 동기화하지 않습니다.
payment.amount는 주문 총액입니다. 결제 상태로 결제액, 환불액 또는 미결제액을 확정할 수 없습니다.