# PawUltra API Docs

>

OpenAPI JSON: https://pawultra.com/openapi.json
OpenAPI YAML: https://pawultra.com/openapi.yaml
Changelog: https://pawultra.com/developers/changelog

## Overview

PawUltra API v1 is a canonical, supplier-neutral B2B integration contract. It is read-oriented, versioned, rate-limited and designed for bulk synchronisation. The documentation is public; customer data requires authenticated access.

The v1 specification covers Catalogue, Media, Pricing, Availability, and Orders & Tracking for ecommerce, ERP and PIM integrations.

## Quickstart

- Become an approved PawUltra trade customer.
- Open My Account → API & Integrations → Request API access. Approval is manual and separate from trade approval.
- At /account/api, create a named API key with catalogue:read for this first request. Choose its expiry and confirm your current password. The secret is shown once; it is not emailed.
- Make your first request to the production API base https://api.pawultra.com/v1.

Keep API keys in server-side environment variables; never commit or log them. The first request below lists a product without assuming any SKU exists. An empty data array is a valid result. All sample product, order and event values elsewhere in this guide are synthetic; use identifiers returned to your account.

```bash
status=$(curl -sS -D headers.txt -o response.json -w '%{http_code}' \
  "https://api.pawultra.com/v1/products?limit=1" \
  -H "Authorization: Bearer $PAWULTRA_API_KEY")
if [ "$status" = 429 ]; then echo 'Rate limited:'; awk 'tolower($1) == "retry-after:" {print $0}' headers.txt;
elif [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then echo "Request failed: $status";
else cat response.json; fi
```

## Authentication and scopes

API authentication uses Authorization: Bearer pu_live_your_api_key. The value shown is a placeholder, never a real key. Only eligible approved trade businesses will receive production access.

Read scopes are catalogue:read, availability:read, pricing:read and orders:read. Catalogue integrations use catalogue:read; catalogue plus availability uses both scopes; a full ecommerce integration uses all four. Webhook subscriptions require webhooks:manage. Order creation and orders:write are outside this API. Grant only necessary scopes.

Manage keys at /account/api: create and name keys, inspect scopes and last-used time, revoke and replace. A business can have at most five active keys. Expiry is required, defaults to 90 days and cannot exceed 365 days. Creating or revoking a key requires your current password. Store keys securely; never embed them in browser applications. PawUltra stores hashes and prefix metadata, not recoverable key secrets.

## API resources

The endpoints below are defined by the v1 specification. OpenAPI is the authoritative definition of request and response fields.

- GET /v1/products — List canonical products (catalogue:read)
- GET /v1/products/{sku} — Get a product (catalogue:read)
- GET /v1/brands — List brands (catalogue:read)
- GET /v1/categories — List categories (catalogue:read)
- GET /v1/availability/{sku} — Get availability (availability:read)
- POST /v1/availability/batch — Read availability in a batch (availability:read)
- GET /v1/catalogue/export — Get a pre-generated catalogue snapshot (catalogue:read)
- GET /v1/products/changes — Read incremental changes (catalogue:read)
- GET /v1/prices/{sku} — Get customer-specific price (pricing:read)
- POST /v1/prices/batch — Read customer-specific prices in a batch (pricing:read)
- GET /v1/orders — List orders (orders:read)
- GET /v1/orders/{id} — Get an order and its shipments (orders:read)
- GET /v1/webhooks — List webhook (webhooks:manage)
- POST /v1/webhooks — Create webhook (webhooks:manage)
- GET /v1/webhooks/{id} — Get webhook (webhooks:manage)
- PATCH /v1/webhooks/{id} — Update webhook (webhooks:manage)
- DELETE /v1/webhooks/{id} — Delete webhook (webhooks:manage)
- POST /v1/webhooks/{id}/rotate-secret — Rotate webhook (webhooks:manage)
- POST /v1/webhooks/{id}/test — Test webhook (webhooks:manage)

## Products

GET /v1/products lists lightweight ProductSummary records with primary_image and image_count using cursor pagination. GET /v1/products/{sku} retrieves a Product with the complete ordered images array by PawUltra SKU. Bulk exports include complete galleries. The stable id is a UUID. GTIN, description and availability_updated_at can be null. Optional ingredients, composition, analytical_constituents, additives, feeding_guide and directions_or_care contain validated canonical product information only when complete source evidence supports them. Missing or incomplete sections are omitted or null. These fields are included in catalogue exports and representation versions. All other fields shown are required in the contract.

```json
{
  "id": "123e4567-e89b-42d3-a456-426614174000",
  "sku": "PU-DOG-1234",
  "title": "Example Complete Pet Food 2kg",
  "brand": "Example Brand",
  "gtin": null,
  "category": {
    "name": "Dogs",
    "slug": "dogs"
  },
  "description": null,
  "availability": "available",
  "availability_updated_at": "2026-10-30T16:30:00Z",
  "images": [
    {
      "url": "https://media.pawultra.com/example/product.jpg",
      "position": 1,
      "primary": true,
      "width": 1200,
      "height": 1200
    }
  ],
  "updated_at": "2026-10-30T16:30:00Z"
}
```

The example is synthetic. Product descriptions do not contain customer-specific pricing. The public documentation does not grant access to catalogue data.

## Product media and usage

Product Media reflects PawUltra’s canonical editorial gallery. Images are fetched directly from PawUltra-hosted media URLs, without proxying binary files through the product API. ProductImage contains url, position, primary, width and height. Position starts at 1; only the first image is primary. Width and height are validated pixels. URLs identify the current hosted representation; clients should refresh product data for changes. Product lists expose primary_image and image_count; product detail and bulk exports provide the full ordered gallery.

Product Media supplied through PawUltra is subject to PawUltra’s applicable trade and media usage terms. Cache media URLs responsibly and refresh product data when the gallery changes. Customer media uploads are not part of this API.

## Brands and categories

GET /v1/brands and GET /v1/categories return canonical navigation metadata as {"data": [{"name": "Example", "slug": "example"}]}. Each entry has a name and slug. They require catalogue:read.

## Pricing and VAT

GET /v1/prices/{sku} and POST /v1/prices/batch require pricing:read and an eligible authenticated PawUltra business. A price belongs to that business. Do not assume another account receives the same price or cache prices globally with canonical product data.

```json
{ "sku": "PU-DOG-1234", "currency": "GBP", "net_price": "12.40", "vat_rate": "20.00", "price_updated_at": "2026-10-30T18:30:00Z" }
```

net_price is the business’s PawUltra trade selling price before VAT. Monetary values and VAT percentage are decimal strings; currency is GBP. VAT treatment varies by product. Batch requests use a bounded skus array and return data for authorised valid SKUs plus not_found for missing SKUs.

```json
{ "skus": ["PU-DOG-0001", "PU-CAT-0001"] }
```

## Orders, shipments and tracking

GET /v1/orders and GET /v1/orders/{id} require a valid API key, orders:read and ownership by the authorised PawUltra business. Knowing an order ID does not grant access. Order lists use bounded cursor pagination and stable ordering. Orders are read-only in this contract.

```json
{ "id": "ord_example", "number": "PU-10482", "status": "processing", "fulfilment_status": "partially_fulfilled", "created_at": "2026-10-30T12:00:00Z", "updated_at": "2026-10-30T18:40:00Z", "shipments": [{ "id": "shp_example", "carrier": { "code": "example", "name": "Example Carrier" }, "tracking_number": null, "tracking_url": null, "status": "in_transit", "dispatched_at": "2026-10-30T15:10:00Z", "estimated_delivery_at": null, "delivered_at": null, "tracking_updated_at": "2026-10-30T18:42:00Z" }] }
```

A PawUltra order may contain multiple shipments. Iterate shipments rather than assuming one order has one tracking number. Shipment status is normalised by PawUltra: unknown, dispatched, in_transit, out_for_delivery, delivered, exception or cancelled. Carrier code and name are generic; no particular carrier is guaranteed.

Order details include frozen merchandise, delivery and VAT totals, the delivery address, and item prices where available. tracking_url links to the official carrier tracking experience when recorded. Tracking information becomes available when recorded for the shipment. Order reads use PawUltra’s stored shipment state rather than requiring a live carrier request. tracking_updated_at indicates the freshness of the recorded state.

Carrier, tracking number and URL, dispatched_at, estimated_delivery_at, delivered_at and tracking_updated_at may be null when unknown. A tracking number does not establish physical dispatch. Timestamps are populated only when authoritative; an unknown delivery estimate is null. Optional shipment items contain SKU and quantity only when allocation is known.

Use cursor pagination and notifications with periodic reconciliation to detect changes. Avoid repeatedly polling every historical order.

## Availability

GET /v1/availability/{sku} and POST /v1/availability/batch require availability:read. Availability is either available or unavailable; availability_updated_at is a PawUltra-facing timestamp or null. Availability is not a reservation or guarantee.

```json
{ "skus": ["PU-DOG-0001", "PU-CAT-0001"] }
```

```json
{ "data": [{ "sku": "PU-DOG-0001", "availability": "available", "availability_updated_at": null }], "not_found": ["PU-CAT-0001"] }
```

Batch size is bounded to 100 input SKUs by default. Duplicate SKUs are deduplicated. The max_orderable_quantity concept is a PawUltra commercial limit for an attempted order, not a reservation. Its policy is not defined and the field is not in the initial contract. Special-order membership alone does not establish availability.

## Efficient catalogue synchronisation

First sync: GET /v1/catalogue/export returns a short-lived download URL to a pre-generated NDJSON gzip snapshot, its catalogue version, count, generation time and associated changes_cursor. Download before expires_at; request a fresh export if it expires. Each NDJSON line is a full Product with its editorial gallery. The snapshot is not generated on every request.

Normal sync: start GET /v1/products/changes with the export changes_cursor, then persist and reuse each returned next_cursor. If has_more is true, request the next page immediately; otherwise keep the checkpoint for the next poll. Changes use created, updated, unpublished or republished; retrieve current product data for changed SKUs and remove unpublished records from customer displays. Persist the checkpoint only after successfully applying the page. Cursors are opaque: do not parse or construct them. If a cursor is invalid or expired, perform a fresh export.

```json
{ "changes": [{ "sku": "PU-DOG-1234", "action": "updated", "updated_at": "2026-10-30T16:30:00Z" }, { "sku": "PU-CAT-4567", "action": "unpublished", "updated_at": "2026-10-30T16:31:00Z" }], "next_cursor": "opaque_checkpoint", "has_more": false }
```

A primary change, gallery reorder, image addition or removal can appear as a product change; retrieve the current product afterwards. Use bounded pricing and availability batches for commercial state and direct media delivery for images. Avoid fetching every individual product every few minutes. Webhooks provide an additional notification, not a substitute for reconciliation.

## Pagination and caching

GET /v1/products defaults to limit=50. Pass its server-issued next_cursor as the cursor parameter until next_cursor is null. The default maximum page size is 100. Treat cursors as opaque; do not assume a particular representation or construct one.

Product reads support ETag and If-None-Match; 304 Not Modified has no response body. Delivery infrastructure can omit custom request and quota headers on 304 responses; authentication and quota consumption still apply. Do not require those headers when handling a 304. Snapshot version identifies a catalogue export. Do not treat cached availability as a reservation.

## Client examples

These server-side examples check HTTP failures and surface throttling. Set PAWULTRA_API_KEY securely. For SKU-based requests, set PAWULTRA_SKU to a real SKU returned by GET /v1/products; do not copy synthetic SKU values from response illustrations. Handle Retry-After before scheduling a retry; do not retry authentication failures in a loop.

```bash
# Conditional navigation request: replace SAVED_ETAG with an ETag saved earlier.
curl --fail-with-body -i "https://api.pawultra.com/v1/brands" -H "Authorization: Bearer $PAWULTRA_API_KEY" -H 'If-None-Match: "SAVED_ETAG"'
# PAWULTRA_SKU must come from an authorised /products response.
: "${PAWULTRA_SKU:?Set a real SKU from /products}"
payload=$(PAWULTRA_SKU="$PAWULTRA_SKU" python3 -c 'import json,os; print(json.dumps({"skus":[os.environ["PAWULTRA_SKU"]]}))')
curl --fail-with-body "https://api.pawultra.com/v1/availability/batch" -H "Authorization: Bearer $PAWULTRA_API_KEY" -H 'Content-Type: application/json' -d "$payload"
curl --fail-with-body "https://api.pawultra.com/v1/prices/batch" -H "Authorization: Bearer $PAWULTRA_API_KEY" -H 'Content-Type: application/json' -d "$payload"
# Discover your own order IDs rather than assuming an example order exists.
curl --fail-with-body "https://api.pawultra.com/v1/orders?limit=1" -H "Authorization: Bearer $PAWULTRA_API_KEY"
```

```javascript
// Node.js 22+; keep this code on your server.
const base = 'https://api.pawultra.com/v1';
if (!process.env.PAWULTRA_API_KEY) throw new Error('Configure PAWULTRA_API_KEY');
let cursor;
do {
  const url = new URL(base + '/products');
  url.searchParams.set('limit', '50');
  if (cursor) url.searchParams.set('cursor', cursor);
  const response = await fetch(url, { signal: AbortSignal.timeout(15000), headers: { Authorization: 'Bearer ' + process.env.PAWULTRA_API_KEY } });
  if (response.status === 429) throw new Error('Rate limited; retry after ' + (response.headers.get('Retry-After') || 'a delay'));
  if (!response.ok) throw new Error('PawUltra request failed: ' + response.status);
  const page = await response.json();
  // Process page.data before persisting the cursor.
  cursor = page.next_cursor;
} while (cursor);
```

```python
import json, os, urllib.parse, urllib.request, urllib.error
cursor = None
while True:
    params = {'limit': 50}
    if cursor: params['cursor'] = cursor
    request = urllib.request.Request('https://api.pawultra.com/v1/products?' + urllib.parse.urlencode(params),
        headers={'Authorization': 'Bearer ' + os.environ['PAWULTRA_API_KEY']})
    try:
        with urllib.request.urlopen(request, timeout=15) as response: page = json.load(response)
    except urllib.error.HTTPError as error:
        if error.code == 429: raise RuntimeError('Rate limited; retry after ' + (error.headers.get('Retry-After') or 'a delay')) from None
        raise RuntimeError('PawUltra request failed: ' + str(error.code)) from None
    # Process page['data'] before persisting the cursor.
    cursor = page['next_cursor']
    if cursor is None: break
```

## Rate limits and errors

New API access approvals default to 60 requests per minute per key and 120 requests per minute per business. These are shared business quotas: multiple keys do not multiply capacity. Some existing businesses retain legacy or individually agreed limits. Check the effective limits in API & Integrations or contact PawUltra; do not assume the new defaults apply retroactively. Additional abuse protection may apply. Quota headers are RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (Unix seconds). On 429 Too Many Requests, respect Retry-After in seconds. Pause requests until the indicated reset; avoid a burst of simultaneous retries. RateLimit-Limit reflects the lower of the effective key/business limits, and RateLimit-Remaining the smaller remaining allowance.

Errors use a stable code, safe message and request_id. Expired change cursors return 410 changes_cursor_expired; obtain a fresh export. Missing effective customer prices return 404 price_not_found. Responses include X-PawUltra-Request-Id for support, except that delivery infrastructure can omit it on 304 responses. Common results include 400 validation_error or invalid_cursor, 401 invalid_api_key, 403 insufficient_scope or api_access_suspended, 404 not_found or price_not_found, 410 changes_cursor_expired, 422 validation_error, 429 rate_limited, 500 internal_error and 503 temporarily_unavailable or export_unavailable. Use the actual error.code rather than assuming every status occurs on every endpoint.

A missing, malformed, unknown, expired or revoked API key returns 401 invalid_api_key. Suspension of API access, or loss of trade eligibility, returns 403 api_access_suspended for an otherwise valid key. Missing key or business permissions return 403 insufficient_scope. After PawUltra re-enables API access, an unexpired, unrevoked key can work again if its scopes remain authorised; reactivation does not undo revocation or expiry. A resource outside your business returns 404 not_found. Do not keep retrying 401/403: check the credential and account status.

```json
{ "error": { "code": "not_found", "message": "No product was found for this PawUltra SKU.", "request_id": "req_example" } }
```

## Versions and roadmap

The base URL is https://api.pawultra.com/v1. Backward-compatible optional fields may be added within v1. Breaking field or semantic changes require a future major version or formal deprecation process. Clients should ignore undocumented optional fields.

Webhook notifications include product.updated, product.unpublished, availability.updated, price.updated, order.updated, shipment.dispatched, shipment.updated, shipment.out_for_delivery, shipment.delivered and shipment.exception. Notifications contain PawUltra-normalised identifiers and status; retrieve current state and periodically reconcile because delivery is not a transactional guarantee.

```json
{ "id": "evt_0123456789abcdef0123456789abcdef", "type": "shipment.delivered", "created_at": "2026-10-30T16:30:00Z", "data": { "order_id": "ord_example", "shipment_id": "shp_example", "status": "delivered" } }
```

## Webhook subscriptions and verification

Use webhooks:manage to list or create /v1/webhooks, get/update/delete /v1/webhooks/{id}, rotate its secret with POST /v1/webhooks/{id}/rotate-secret, or enqueue a test with POST /v1/webhooks/{id}/test. Endpoints belong to your authenticated business. At most 10 non-deleted subscriptions per business are supported, including disabled subscriptions. Destinations must be public HTTPS URLs on port 443; redirects are not followed.

```json
{ "url": "https://example.com/pawultra/events", "events": ["product.updated", "shipment.delivered"], "enabled": true }
```

Webhook resource IDs use wh_ followed by 32 hexadecimal characters. Creation and explicit rotation return a signing secret once; GET and list never return it. Store it securely. Check X-PawUltra-Webhook-Id and X-PawUltra-Webhook-Timestamp. The signature header X-PawUltra-Webhook-Signature is v1= followed by HMAC-SHA256 in lowercase hex, using the returned secret string as the key and timestamp + '.' + the exact raw JSON request body as the message.

Reject timestamps more than five minutes in the past or future and compare signatures in constant time. X-PawUltra-Webhook-Id equals the body id: real event IDs are evt_ followed by 32 hexadecimal characters, not UUIDs. Deduplicate durably by that event ID; retries carry the same event ID but may have a new timestamp/signature. Return 2xx only after durable acceptance, including already accepted duplicates. Delivery can be repeated, delayed, missed or arrive out of order; reconcile periodically with the read endpoints and catalogue changes/export checkpoints. price.updated can contain a SKU or all:true when the assigned pricebook changes.

POST /v1/webhooks/{id}/test returns HTTP 200 when a webhook.test event has been accepted. It does not wait for or prove HTTP delivery to your receiver. Delivery is asynchronous; confirm the event at your endpoint and inspect last_success_at/last_failure_at as endpoint-level signals, not per-event receipts. The subscription must be enabled and API access/scopes must remain valid for delivery. Delivery also requires the relevant authorised read permission: catalogue:read for product events, availability:read for availability, pricing:read for prices, and orders:read for orders/shipments, alongside webhooks:manage. webhook.test is generated by /test, not a selectable subscription event.

Non-2xx responses and transport failures are retried with bounded exponential backoff, up to six attempts. Do not rely on an exact delivery time or retry interval. Disabled/deleted subscriptions or ineligible access can prevent delivery. After changing an endpoint or rotating its secret, configure the receiver before requesting a test and verify a new event signed with the replacement secret. Rotation does not change the event ID format or permit a revoked API key.

Complete Node.js 22+ example below: save as receiver.mjs, set PAWULTRA_WEBHOOK_SECRET and QUEUE_DIR, then run node receiver.mjs behind your own HTTPS proxy. The sample path is YOUR receiver, not a PawUltra API route. QUEUE_DIR must be private durable storage supporting atomic hard links and fsync, not ephemeral serverless storage. It acts as a durable inbox, not a full processing service. A separate worker must process entries idempotently; retain deduplication records for your retry/reconciliation policy. Apply your own storage, retention and access controls for event data. For serverless receivers, replace the filesystem hand-off with your existing durable queue/database. Do not acknowledge before that hand-off succeeds. Use a separate configured signing secret for each subscription.

```javascript
// Example receiver: Node.js 22+, behind your own HTTPS reverse proxy.
// QUEUE_DIR must be a private directory on durable storage, NOT serverless /tmp.
import { createServer } from 'node:http';
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, open, link, unlink } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';

export function createWebhookServer({ secret, queueDir }) {
  if (!secret || !queueDir) throw new Error('Configure signing secret and durable queue directory');
  const directory = resolve(queueDir);
  return createServer(async (request, response) => {
    const reply = (status) => {
      response.writeHead(status, { 'Cache-Control': 'no-store', ...(status === 405 ? { Allow: 'POST' } : {}) });
      response.end();
    };
    if (request.url !== '/webhooks/pawultra') return reply(404);
    if (request.method !== 'POST') return reply(405);
    if (request.headers['content-type']?.split(';')[0].trim() !== 'application/json' || request.headers['content-encoding']) return reply(415);
    const timestamp = request.headers['x-pawultra-webhook-timestamp'];
    const signature = request.headers['x-pawultra-webhook-signature'];
    const eventId = request.headers['x-pawultra-webhook-id'];
    if (typeof timestamp !== 'string' || !/^\d{1,12}$/.test(timestamp) ||
        Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
        typeof signature !== 'string' || !/^v1=[a-f0-9]{64}$/.test(signature) ||
        typeof eventId !== 'string' || !/^evt_[a-f0-9]{32}$/.test(eventId)) return reply(401);
    // These size/time limits are example receiver policy, not PawUltra API quotas.
    const maximum = 65536;
    if (Number(request.headers['content-length']) > maximum) return reply(413);
    const timer = setTimeout(() => request.destroy(), 5000);
    timer.unref();
    let temporary;
    try {
      let length = 0;
      const chunks = [];
      for await (const chunk of request) {
        length += chunk.length;
        if (length > maximum) return reply(413);
        chunks.push(chunk);
      }
      clearTimeout(timer);
      const rawBody = Buffer.concat(chunks); // Never parse/re-serialise before checking HMAC.
      const expected = createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest();
      const received = Buffer.from(signature.slice(3), 'hex');
      if (received.length !== expected.length || !timingSafeEqual(received, expected)) return reply(401);
      let event;
      try { event = JSON.parse(rawBody.toString('utf8')); } catch { return reply(400); }
      if (!event || event.id !== eventId || typeof event.type !== 'string') return reply(400);
      await mkdir(directory, { recursive: true, mode: 0o700 });
      temporary = join(directory, '.' + randomUUID() + '.tmp');
      const file = await open(temporary, 'wx', 0o600);
      try { await file.writeFile(rawBody); await file.sync(); } finally { await file.close(); }
      try {
        // Atomic durable inbox entry; an existing event ID is an idempotent duplicate.
        await link(temporary, join(directory, eventId + '.json'));
      } catch (error) { if (error.code !== 'EEXIST') throw error; }
      const folder = await open(directory, 'r');
      try { await folder.sync(); } finally { await folder.close(); }
      return reply(204); // Only after acceptance into durable storage.
    } catch {
      return reply(503); // No secrets, headers or event bodies are logged.
    } finally {
      clearTimeout(timer);
      if (temporary) await unlink(temporary).catch(() => {});
    }
  });
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
  const server = createWebhookServer({ secret: process.env.PAWULTRA_WEBHOOK_SECRET, queueDir: process.env.QUEUE_DIR });
  server.requestTimeout = 10000;
  server.headersTimeout = 10000;
  server.listen(8080, '127.0.0.1');
}

```

## API access

API access is available to approved PawUltra businesses. Go to My Account → API & Integrations → Request API access, describe your integration and wait for manual approval. Use https://pawultra.com/account/api to check progress or provide additional information securely. After API approval, create your own API key; its secret is shown once and is never sent by email.

Contact info@pawultra.com for API enquiries or open a PawUltra trade account.
