# Recipes: complete scripts for common Data API tasks

> Complete, tested scripts in bash, Python and TypeScript for common Data API tasks, with a shared helper that handles rate limits and running out of credits.

*Source: https://shopsavvy.com/data/documentation/recipes* · Part of the [ShopSavvy Data API documentation](https://shopsavvy.com/data/documentation.md)

---

Five tasks from start to finish, each as a complete script in three languages: bash with `curl` and `jq`, Python 3 with `requests`, and TypeScript for Node 18+ (run it with `npx tsx`) or Bun. Every script on these pages was run against the live API before it was published. Each one reads your key from the `SHOPSAVVY_API_KEY` environment variable.

The scripts call the REST API directly rather than through an SDK so they can tell the two kinds of HTTP 429 apart: `ERR_RATE_LIMIT` means wait the `Retry-After` seconds and try again, `ERR_CREDITS_EXHAUSTED` means stop, because retrying will not help until the period resets (see [Errors](https://shopsavvy.com/data/documentation/errors)). Save the helper for your language next to the recipe scripts; every recipe uses it.

### Helper: bash (shopsavvy-call.sh)

```bash
# Source this file: . ./shopsavvy-call.sh   (needs curl and jq; SHOPSAVVY_API_KEY in the environment)
# api METHOD PATH [JSON_BODY]: prints the response body. Waits out ERR_RATE_LIMIT (Retry-After),
# stops on ERR_CREDITS_EXHAUSTED and on any other error except a 404 (no product matched).
api() {
  local attempt status
  for attempt in 1 2 3 4 5; do
    if [ $# -ge 3 ]; then
      status=$(curl -sS -o "$SS_TMP/body" -D "$SS_TMP/headers" -w '%{http_code}' -X "$1" "https://api.shopsavvy.com/v1$2" \
        -H "Authorization: Bearer $SHOPSAVVY_API_KEY" -H 'Content-Type: application/json' -d "$3")
    else
      status=$(curl -sS -o "$SS_TMP/body" -D "$SS_TMP/headers" -w '%{http_code}' -X "$1" "https://api.shopsavvy.com/v1$2" \
        -H "Authorization: Bearer $SHOPSAVVY_API_KEY")
    fi
    if [ "$status" = 429 ]; then
      if [ "$(jq -r '.error_detail.code' "$SS_TMP/body")" = ERR_CREDITS_EXHAUSTED ]; then
        jq -r '.error_detail.action' "$SS_TMP/body" >&2
        return 2
      fi
      sleep "$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' "$SS_TMP/headers")"
      continue
    fi
    if [ "$status" -ge 400 ] && [ "$status" != 404 ]; then
      jq -r '"\(.error_detail.code): \(.error_detail.message) \(.error_detail.action)"' "$SS_TMP/body" >&2
      return 1
    fi
    cat "$SS_TMP/body"
    return 0
  done
  echo "Still rate limited after 5 attempts" >&2
  return 1
}
SS_TMP=$(mktemp -d)
```

### Helper: Python (shopsavvy_call.py)

`pip install requests`

```python
import os
import time

import requests

API = "https://api.shopsavvy.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SHOPSAVVY_API_KEY']}"}


class CreditsExhausted(Exception):
    """This month's credits are used up: waiting will not help until the period resets."""


def call(method, path, not_found_ok=False, **kwargs):
    """One Data API request. Waits out ERR_RATE_LIMIT (Retry-After) and stops on ERR_CREDITS_EXHAUSTED."""
    for _ in range(5):
        response = requests.request(method, API + path, headers=HEADERS, timeout=120, **kwargs)
        if response.status_code == 429:
            detail = response.json().get("error_detail", {})
            if detail.get("code") == "ERR_CREDITS_EXHAUSTED":
                raise CreditsExhausted(detail.get("action"))
            time.sleep(int(response.headers.get("Retry-After", "60")))
            continue
        if response.status_code == 404 and not_found_ok:
            return response
        if response.status_code >= 400:
            detail = response.json().get("error_detail", {})
            raise RuntimeError(f"{detail.get('code')}: {detail.get('message')} {detail.get('action', '')}")
        return response
    raise RuntimeError("Still rate limited after 5 attempts")
```

### Helper: TypeScript (shopsavvy-call.ts)

No dependencies: it uses the built-in `fetch`.

```typescript
const API = 'https://api.shopsavvy.com/v1'
const KEY = process.env.SHOPSAVVY_API_KEY

export class CreditsExhausted extends Error {}

// One Data API request. Waits out ERR_RATE_LIMIT (Retry-After) and stops on ERR_CREDITS_EXHAUSTED.
export async function call(method: string, path: string, options: { body?: unknown, notFoundOk?: boolean } = {}): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const response = await fetch(API + path, {
      method,
      headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
      body: options.body === undefined ? undefined : JSON.stringify(options.body),
    })
    if (response.status === 429) {
      const detail = (await response.json()).error_detail ?? {}
      if (detail.code === 'ERR_CREDITS_EXHAUSTED') throw new CreditsExhausted(detail.action)
      await new Promise((resolve) => setTimeout(resolve, Number(response.headers.get('Retry-After') ?? 60) * 1000))
      continue
    }
    if (response.status === 404 && options.notFoundOk) return response
    if (response.status >= 400) {
      const detail = (await response.json()).error_detail ?? {}
      throw new Error(`${detail.code}: ${detail.message} ${detail.action ?? ''}`)
    }
    return response
  }
  throw new Error('Still rate limited after 5 attempts')
}

// One CSV line, quoting any field that needs it.
export function csvLine(fields: unknown[]): string {
  return fields.map((field) => {
    const text = field === undefined || field === null ? '' : String(field)
    return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
  }).join(',') + '\n'
}
```
