Skip to content

DevelopersThe API and webhooks

The systems around your store, in on it.

A REST API with keys you scope to a role, and signed webhooks the moment a sale, a count or a price changes. On the Chain plan and above. This page is the short version; the full reference is generated from the code that serves it.

01

Authentication

Create a key in Settings → Developers and send it as a bearer token. It is shown once; PalletRow keeps only a fingerprint of it.

curl https://app.palletrow.com/api/v1/products/{id} \
  -H "Authorization: Bearer prk_live_…"

Scoped to a role

A key acts with the permissions of the role you choose when you make it — a read-only auditor, an inventory manager — and never more than the person who made it can do today.

Every store

A key works across every store in the workspace, and the database’s row-level security keeps it inside yours.

Answerable to a person

Changes made with a key are audited under the person who created it, marked as made through that key. If they leave the workspace, the key stops.

Session-only corners

Billing, the assistant, account and session settings, inviting people, and managing keys and webhooks cannot be reached with a key. They answer 403.

02

Limits and errors

120 requests a minute per key. Every error is JSON with a sentence in message — never a redirect to a sign-in page.

401The key is missing, malformed, unknown, revoked or expired.
402The plan does not include the API, or the account is paused for billing. `code` says which, and names the plan that fixes it.
403The key’s role lacks the permission the endpoint needs — named in the body — or the endpoint is session-only.
422The body did not match the schema. Permission is checked first, so you never get a schema error for an endpoint you may not use.
429Over the rate limit. Wait for the `Retry-After` seconds.
03

The reference

An OpenAPI 3.1 document, generated on every deploy from the same schemas the endpoints validate against — so it cannot describe a field the API does not take. Load it into Postman, Insomnia or a client generator.

app.palletrow.com/api/v1/openapi.json

Every endpoint lists the permission it needs, and each webhook event is described in the document’s webhooks section.

04

Webhooks

Add an https endpoint in Settings → Developers, choose events, and PalletRow POSTs a signed JSON envelope within seconds of each change.

sale.createdA sale or refund was recorded, from a connected POS or a sales file.
sale_id · location_id · source · external_id · is_refund · subtotal · tax_total · total · line_count · unmatched_line_count · transacted_at
inventory.adjustedStock was changed by hand: an adjustment, waste, a count correction or a reversal.
transaction_id · location_id · product_id · reason · quantity_delta · quantity_after
purchase_order.status_changedA purchase order moved: submitted, approved, sent, cancelled, partly or fully received.
purchase_order_id · po_number · vendor_id · location_id · previous_status · status · total
product.createdA product was added to the catalog.
product_id · sku · name · status · base_unit · price
product.updatedA product’s details changed.
product_id · sku · name · status · changed_fields
price.changedA shelf price changed, company-wide or for one store.
product_id · location_id · previous_price · price · reason
count.postedA stock count was posted and its corrections applied.
count_id · location_id · name · corrections · total_variance_value
transfer.status_changedA transfer between stores was dispatched, received or cancelled.
transfer_id · from_location_id · to_location_id · previous_status · status
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: PalletRow-Webhooks/1.0
PalletRow-Event-Id: evt_3f9c2a71d04b8e6a5c1d0f27
PalletRow-Event-Type: sale.created
PalletRow-Signature: t=1759190400,v1=6b1f…(64 hex)

{
  "id": "evt_3f9c2a71d04b8e6a5c1d0f27",
  "type": "sale.created",
  "created": "2026-09-30T14:00:00.000Z",
  "organization_id": "9d2c…",
  "data": {
    "sale_id": "…",
    "location_id": "…",
    "source": "square",
    "external_id": "Rk3…",
    "is_refund": false,
    "subtotal": "42.1800",
    "tax_total": "2.9000",
    "total": "45.0800",
    "line_count": 6,
    "unmatched_line_count": 0,
    "transacted_at": "2026-09-30T13:59:41.000Z"
  }
}

Success is a 2xx

Answer within 10 seconds with any 2xx. Redirects are not followed — use the final URL.

Retries for two days

Anything else is retried after 1 minute, 5, 30, then 2, 6, 12 and 24 hours. The delivery log in settings shows every attempt and can resend.

Deduplicate on id

Delivery is at least once. A retry or a manual resend carries the same evt_ id, and events can arrive out of order.

Switched off, not forgotten

An endpoint that fails every delivery for three days is disabled and the workspace owners are emailed. Enabling it again resumes delivery.

05

Verifying a delivery

PalletRow-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> under the endpoint’s whsec_ secret. Reject a timestamp more than five minutes old, and compare in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto';

// Use the RAW request body — the exact bytes received, before JSON.parse.
export function verifyPalletRow(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.trim().split('=', 2)),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Express, for example:
app.post('/palletrow', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyPalletRow(
    req.body.toString('utf8'),
    req.get('PalletRow-Signature') ?? '',
    process.env.PALLETROW_WEBHOOK_SECRET,
  );
  if (!ok) return res.status(400).end();

  const event = JSON.parse(req.body);
  // Deduplicate on event.id — a retry or a resend carries the same one.
  res.status(204).end();
});

Rotating the secret in settings takes effect on the next attempt, retries included. Use “Send test event” to check a receiver end to end.

Building something? Tell us.

Questions about the API, an event you need that is not here, or a security report go to [email protected].

Developers — PalletRow