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.
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.
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.
| 401 | The key is missing, malformed, unknown, revoked or expired. |
|---|---|
| 402 | The plan does not include the API, or the account is paused for billing. `code` says which, and names the plan that fixes it. |
| 403 | The key’s role lacks the permission the endpoint needs — named in the body — or the endpoint is session-only. |
| 422 | The body did not match the schema. Permission is checked first, so you never get a schema error for an endpoint you may not use. |
| 429 | Over the rate limit. Wait for the `Retry-After` seconds. |
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.
Webhooks
Add an https endpoint in Settings → Developers, choose events, and PalletRow POSTs a signed JSON envelope within seconds of each change.
| sale.created | A 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.adjusted | Stock 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_changed | A 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.created | A product was added to the catalog. product_id · sku · name · status · base_unit · price |
| product.updated | A product’s details changed. product_id · sku · name · status · changed_fields |
| price.changed | A shelf price changed, company-wide or for one store. product_id · location_id · previous_price · price · reason |
| count.posted | A stock count was posted and its corrections applied. count_id · location_id · name · corrections · total_variance_value |
| transfer.status_changed | A 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.
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].