Read a shop's catalog, stock, customers and sales, and record sales, from your own software. Everything is JSON over HTTPS under https://counterpoint-api.fly.dev/v1. The Partner API is part of the Scale plan.
Authentication
Create a key in the back office under Setup → Developer and send it as a bearer token:
Authorization: Bearer cp_live_<id>_<secret>
The key is shown once when created; Counterpoint stores only a hash, so a lost key is revoked and replaced. A missing, malformed, unknown or revoked key all answer the same 401, so the API cannot be used to learn which keys exist.
Each key carries scopes and nothing is implied: catalog:write does not grant catalog:read. A route needs the one scope named on it; a key without it gets 403 naming the missing scope. Scopes: catalog:read, catalog:write, inventory:read, customers:read, customers:write, sales:read, sales:write, reports:read, webhooks:manage.
Test keys and live keys
A key is either live (cp_live_…) or test (cp_test_…), and the environment is part of the credential: a test key does not work as a live one.
A test key reads real shop data, which is what makes it useful while you build. Every write (POST /products, POST /customers, POST /sales, POST /webhooks, DELETE /webhooks/{id}) is validated in full and answered with the result it would have produced, but nothing is saved. The response carries "dryRun": true and the header X-Counterpoint-Dry-Run: 1. No record is created, no stock moves, no receipt number is used up, and no webhook is sent for it. The id and receipt number in a dry-run response are real-looking previews and do not exist afterwards.
Because a test key never registers an endpoint, test webhook delivery by registering the endpoint with a live key (or from Setup → Developer) and acting in a shop you are free to experiment in.
Rate limits
Each key has a per-minute budget (default 120, up to 6000 on request) in fixed clock-aligned one-minute windows. Every authenticated response carries:
X-RateLimit-Limit: requests allowed per windowX-RateLimit-Remaining: requests left in this windowX-RateLimit-Reset: Unix time when the window resets
Over the limit you get 429 with Retry-After (seconds). Failed requests (including 403) count against the limit.
Errors
Errors are JSON and always carry a correlationId. Quote it when you contact support: it finds the request in our logs.
{ "statusCode": 403, "message": "This key is missing the sales:read scope", "error": "Forbidden", "correlationId": "7f3a…" }A body that fails validation answers 400 with { "formErrors": [...], "fieldErrors": { "field": ["reason"] }, "correlationId": "…" }. A plan that does not include the Partner API (or a suspended account attempting a write) answers 402.
List endpoints return a plain JSON array by default. For GET /v1/sales, set paginate=true&limit=200 to receive {sales,nextCursor}. Pass nextCursor as cursor until it is null. There is no total history cap. Optional since is an inclusive ISO creation timestamp and must stay unchanged across pages. Sales are ordered by creation time and ID, newest first. New arrivals during a walk are picked up on the next sync. Save checkpoints only after the whole walk succeeds, overlap time windows and upsert by sale ID. Webhooks provide prompt events; pagination recovers missed new sales. Refunds or changes to older sales require their corresponding events or reconciliation reads. Legacy calls without pagination parameters still return the latest limit sales (default 50, maximum 200).
Money and times
Money is a decimal string ("136.80"), never a float. Times are ISO 8601 in UTC.
Idempotency
POST /v1/sales is idempotent: send an idempotencyKey and a retry returns the original sale instead of a second one. POST /v1/products is protected by the unique SKU, and POST /v1/webhooks refuses a URL that is already registered. POST /v1/customers is not idempotent: a retry creates another customer, so do not blindly retry it after a timeout.
Webhooks
Register an endpoint with POST /v1/webhooks (or in Setup → Developer) and Counterpoint will POST a JSON envelope to it when an event you subscribed to happens:
{ "event": "sale.created", "createdAt": "2026-10-06T09:41:12.000Z", "data": { … } }Each delivery carries x-counterpoint-event (the event name) and x-counterpoint-delivery (a unique id, stable across retries: use it to ignore duplicates). Respond with any 2xx within 5 seconds. Anything else, or a timeout, is retried up to 6 attempts in total with exponential back-off (5s, 15s, 45s, 135s, … capped at 30 minutes). After 10 consecutive failures the endpoint is switched off, not deleted; the merchant sees why in the back office and can switch it back on. Events are queued in the same transaction as the thing they describe, so you are never told about a sale that was rolled back. Events from test keys are never delivered. Delivery order is not guaranteed across retries.
Verifying a signature
Every delivery is signed with the endpoint's secret (whsec_…, shown once at registration). The header is:
x-counterpoint-signature: t=<unix seconds>,v1=<hex>
v1 is the lowercase hex HMAC-SHA256, keyed by the secret, of the string "<t>.<raw request body>". The timestamp is inside the signed text, so a captured request cannot be replayed with a fresh timestamp. Reject deliveries whose t is more than 300 seconds from your clock. Compute over the raw bytes you received, before parsing, and compare in constant time.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
// `rawBody` must be the exact bytes Counterpoint sent, as a string. Verify BEFORE you
// JSON.parse, because re-serialising changes the bytes and the signature with them.
export function verifyCounterpointWebhook(secret, rawBody, signatureHeader, toleranceSeconds = 300) {
const parts = new Map(
String(signatureHeader ?? '')
.split(',')
.map((part) => part.trim().split('='))
.filter((pair) => pair.length === 2),
);
const timestamp = Number(parts.get('t'));
const presented = parts.get('v1');
if (!Number.isFinite(timestamp) || !presented) return false;
// Refuse stale signatures so a captured request cannot be replayed later.
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
const actual = Buffer.from(presented, 'hex');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
// Express:
// app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => {
// const body = req.body.toString('utf8');
// if (!verifyCounterpointWebhook(process.env.WHSEC, body, req.get('x-counterpoint-signature'))) {
// return res.sendStatus(400);
// }
// const event = JSON.parse(body);
// res.sendStatus(200);
// });Python
import hashlib
import hmac
import time
def verify_counterpoint_webhook(secret: str, raw_body: bytes, signature_header: str,
tolerance_seconds: int = 300) -> bool:
"""raw_body must be the exact bytes Counterpoint sent. Verify before parsing JSON."""
parts = dict(
part.strip().split("=", 1)
for part in (signature_header or "").split(",")
if "=" in part
)
try:
timestamp = int(parts["t"])
presented = parts["v1"]
except (KeyError, ValueError):
return False
# Refuse stale signatures so a captured request cannot be replayed later.
if abs(time.time() - timestamp) > tolerance_seconds:
return False
signed = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, presented)
# Flask:
# @app.post("/hooks")
# def hook():
# sig = request.headers.get("x-counterpoint-signature")
# if not verify_counterpoint_webhook(WHSEC, request.get_data(), sig):
# return "", 400
# event = request.get_json()
# return "", 200Catalog
GET/v1/products
List products. Scope catalog:read.
Every product, ordered by name. With no view you get what can be sold: archived products and variant parents are left out. view=catalog returns parent products (variants are counted in variantCount, not listed) and adds cost, supplierId, brandId and archivedAt.
| Parameter | In | Type | Description |
|---|
view | query | "sellable" | "catalog" | sellable (default) or catalog. |
Response 200
| Field | Type | Description |
|---|
id required | string (uuid) | |
sku required | string | |
name required | string | |
unitPrice required | string | |
type required | "standard" | "variant" | "composite" | |
costManual | boolean | |
costExact | string | null | |
variantCount required | integer | |
barcode | string | null | |
cost | string | null | |
supplierId | string (uuid) | null | |
brandId | string (uuid) | null | |
reorderPoint | integer | null | |
reorderQuantity | integer | null | |
archivedAt | string | null | |
categoryId | string (uuid) | null | |
finalSale | boolean | |
description | string | null | |
hiddenOutletIds | string (uuid)[] | |
hiddenOutletCount | integer | |
imageUrl | string | null | |
unit | string | |
purchaseUnit | string | null | |
purchaseFactor | number | null | |
soldOnTill | boolean | |
parentId | string (uuid) | null | |
parentName | string | null | |
Example response 200
[
{
"id": "9c1e5f2a-3b4d-4e6f-8a7b-1c2d3e4f5a6b",
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "120.00",
"type": "standard",
"variantCount": 0,
"barcode": "6221234567891"
}
]curl
curl "https://counterpoint-api.fly.dev/v1/products?view=catalog" \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
POST/v1/products
Create a product. Scope catalog:write.
A test key validates this call and returns the result with "dryRun": true; nothing is saved.
Adds a standard product. The SKU must be unique in the shop. Queues a product.created webhook (not for a test key). Subject to the plan's product limit.
Request body
| Field | Type | Description |
|---|
soldOnTill | boolean | |
sku required | string | |
name required | string | |
type | "standard" | "variant" | "composite" | |
unitPrice | string | |
cost | string | |
categoryId | string (uuid) | |
supplierId | string (uuid) | |
brandId | string (uuid) | |
barcode | string | |
description | string | |
Example request body
{
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "120.00",
"barcode": "6221234567891"
}Response 201
| Field | Type | Description |
|---|
id required | string (uuid) | |
sku required | string | |
name required | string | |
unitPrice required | string | |
dryRun | true | Present and true only when a test key made the call: validated, answered, nothing saved. |
Example response 201
{
"id": "9c1e5f2a-3b4d-4e6f-8a7b-1c2d3e4f5a6b",
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "120.00"
}curl
curl -X POST https://counterpoint-api.fly.dev/v1/products \
-H "Authorization: Bearer $COUNTERPOINT_KEY" \
-H "Content-Type: application/json" \
-d '{"sku":"TSHIRT-BLK-M","name":"Black tee, medium","unitPrice":"120.00"}'GET/v1/categories
List categories. Scope catalog:read.
Product categories, with parentId for the tree.
Response 200
| Field | Type | Description |
|---|
id required | string (uuid) | |
name required | string | |
parentId required | string (uuid) | null | |
Example response 200
[
{
"id": "3a5c7e9b-2d4f-4a6c-8e0b-1d3f5a7c9e2b",
"name": "Apparel",
"parentId": null
}
]curl
curl https://counterpoint-api.fly.dev/v1/categories \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
Inventory
GET/v1/locations
List outlets. Scope inventory:read.
Active outlets (stores and warehouses). The id is what locationId means everywhere else.
Response 200
| Field | Type | Description |
|---|
id required | string (uuid) | |
name required | string | |
locationType required | "store" | "warehouse" | |
numberingPrefix required | string | |
street | string | null | |
city | string | null | |
governate | string | null | |
buildingNumber | string | null | |
phone | string | null | |
taxBranchCode | string | null | |
archivedAt | string | null | |
tillMode | "retail" | "fnb" | |
openTills | integer | |
tillLimit | integer | |
Example response 200
[
{
"id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"name": "Zamalek",
"locationType": "store",
"numberingPrefix": "R-",
"city": "Cairo"
}
]curl
curl https://counterpoint-api.fly.dev/v1/locations \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
GET/v1/inventory
Stock levels at one outlet. Scope inventory:read.
On-hand stock for every product at one outlet. Without locationId the first outlet is used, and the response names which one, so the answer is never silently "one of your outlets".
| Parameter | In | Type | Description |
|---|
locationId | query | string (uuid) | An outlet id from GET /v1/locations. |
Response 200
| Field | Type | Description |
|---|
locationId required | string (uuid) | null | |
levels required | object[] | |
Example response 200
{
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"levels": [
{
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"onHand": 14,
"barcode": "6221234567891"
}
]
}curl
curl "https://counterpoint-api.fly.dev/v1/inventory?locationId=0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
Customers
GET/v1/customers
List customers. Scope customers:read.
Every customer who is not archived, with balances and marketing consent, ordered by name. Archived customers stay out of this list. Their sales are unchanged.
Response 200
| Field | Type | Description |
|---|
id required | string (uuid) | |
code required | string | |
name required | string | |
phone required | string | null | |
email required | string | null | |
onAccount required | "no" | "unlimited" | "limited" | |
creditLimit required | string | null | |
storeCredit required | string | |
accountBalance required | string | |
customerGroup required | string | null | |
loyaltyPoints required | integer | |
tags | string[] | |
marketing required | object | |
marketingChangedAt required | object | |
address required | string | null | |
notes required | string | null | |
birthday required | string | null | |
archivedAt required | string | null | |
Example response 200
[
{
"id": "2f4a6c8e-1b3d-4f5a-9c7e-0a2b4c6d8e1f",
"code": "Mona-3F2A",
"name": "Mona Adel",
"phone": "+201001234567",
"email": "mona@example.com",
"onAccount": "no",
"creditLimit": null,
"storeCredit": "0.00",
"accountBalance": "0.00",
"customerGroup": null,
"loyaltyPoints": 0,
"marketing": {
"whatsapp": false,
"sms": false,
"email": true
},
"marketingChangedAt": {
"whatsapp": null,
"sms": null,
"email": "2026-10-01T10:00:00.000Z"
},
"address": null,
"notes": null,
"birthday": null,
"archivedAt": null
}
]curl
curl https://counterpoint-api.fly.dev/v1/customers \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
POST/v1/customers
Create a customer. Scope customers:write.
A test key validates this call and returns the result with "dryRun": true; nothing is saved.
Adds a customer. The code is generated. Marketing consent is opt-in per channel and defaults to off: only set a channel to true if the customer actually agreed.
Request body
| Field | Type | Description |
|---|
name required | string | |
phone | string | |
email | string (email) | |
onAccount | "no" | "unlimited" | "limited" | |
creditLimit | string | |
customerGroup | string | |
marketingWhatsapp | boolean | |
marketingSms | boolean | |
marketingEmail | boolean | |
address | string | |
notes | string | |
birthday | string | |
Example request body
{
"name": "Mona Adel",
"phone": "+201001234567",
"email": "mona@example.com",
"marketingEmail": true
}Response 201
| Field | Type | Description |
|---|
id required | string (uuid) | |
code required | string | |
name required | string | |
phone required | string | null | |
email required | string | null | |
onAccount required | "no" | "unlimited" | "limited" | |
creditLimit required | string | null | |
storeCredit required | string | |
accountBalance required | string | |
customerGroup required | string | null | |
loyaltyPoints required | integer | |
tags | string[] | |
marketing required | object | |
marketingChangedAt required | object | |
address required | string | null | |
notes required | string | null | |
birthday required | string | null | |
archivedAt required | string | null | |
dryRun | true | Present and true only when a test key made the call: validated, answered, nothing saved. |
Example response 201
{
"id": "2f4a6c8e-1b3d-4f5a-9c7e-0a2b4c6d8e1f",
"code": "Mona-3F2A",
"name": "Mona Adel",
"phone": "+201001234567",
"email": "mona@example.com",
"onAccount": "no",
"creditLimit": null,
"storeCredit": "0.00",
"accountBalance": "0.00",
"customerGroup": null,
"loyaltyPoints": 0,
"marketing": {
"whatsapp": false,
"sms": false,
"email": true
},
"marketingChangedAt": {
"whatsapp": null,
"sms": null,
"email": "2026-10-01T10:00:00.000Z"
},
"address": null,
"notes": null,
"birthday": null,
"archivedAt": null
}curl
curl -X POST https://counterpoint-api.fly.dev/v1/customers \
-H "Authorization: Bearer $COUNTERPOINT_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Mona Adel","email":"mona@example.com"}'Sales
GET/v1/sales
List sales, with optional cursor pagination. Scope sales:read.
Sales ordered by creation time and ID, newest first. Set paginate=true for {sales,nextCursor}; pass nextCursor as cursor until null to walk all history. cursor or since also enables pagination. Keep the optional inclusive ISO creation timestamp since unchanged across pages. Without pagination parameters the legacy array is returned. Sale lines require GET /v1/sales/{id}. Newer arrivals are picked up on the next sync; overlap time windows and upsert by ID. Older-sale refunds require their events or reconciliation reads.
| Parameter | In | Type | Description |
|---|
limit | query | integer | Page size, 1 to 200, default 50. Invalid values are rejected in paginated mode; legacy calls clamp out-of-range values. |
paginate | query | boolean | Return {sales,nextCursor} instead of the legacy array. |
cursor | query | string | Opaque nextCursor from the previous page. Enables pagination. |
since | query | string (date-time) | Inclusive ISO sale creation timestamp. Enables pagination; keep unchanged across pages. |
Response 200
Example response 200
[
{
"id": "5b0f6a52-7f3a-4c8e-9a52-6d1f2f0f7e11",
"receiptNumber": "R-000412",
"currency": "EGP",
"subtotal": "120.00",
"tax": "16.80",
"total": "136.80",
"tender": "cash",
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"createdAt": "2026-10-06T09:41:12.000Z",
"cashReceived": "150.00",
"changeDue": "13.20",
"taxDocumentRef": null,
"etaUuid": null,
"refundedTotal": "0.00",
"refunded": false
}
]curl
curl "https://counterpoint-api.fly.dev/v1/sales?limit=20" \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
POST/v1/sales
Record a sale. Scope sales:write.
A test key validates this call and returns the result with "dryRun": true; nothing is saved.
Rings up a sale. Totals are computed server-side from the lines (and discount), so a client cannot understate them. Stock is decremented at the outlet, a gap-free receipt number is allocated and sale.created is queued.
Idempotent. idempotencyKey (at least 8 characters, yours to generate) makes a retry safe: sending the same key again returns the original sale instead of recording a second one. Always send a stable key per logical sale and reuse it on retries.
Without locationId the shop's first active outlet is used. A cash sale needs an open cash drawer at that outlet.
Request body
| Field | Type | Description |
|---|
locationId | string (uuid) | |
currency required | string | |
registerSessionId | string (uuid) | |
tender required | "card" | "cash" | "cod" | "wallet" | "instapay" | "on_account" | "store_credit" | "talabat" | "shopify" | "loyalty" | "gift_card" | "layaway" | |
lines required | object[] | |
customerId | string (uuid) | |
discount | object | |
discountGroupId | string (uuid) | |
discountNote | string | |
customerPhone | string | |
giftCardCode | string | |
remainderTender | "card" | "cash" | "cod" | "wallet" | "instapay" | "talabat" | "gift_card" | |
loyaltyPoints | integer | |
paymentLinkId | string (uuid) | |
buyerTaxId | string | |
buyerNationalId | string | |
buyerName | string | |
cashReceived | string | |
staffId | string (uuid) | |
approvalId | string (uuid) | |
idempotencyKey required | string | |
Example request body
{
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"currency": "EGP",
"tender": "cash",
"cashReceived": "150.00",
"idempotencyKey": "order-48213-attempt",
"lines": [
{
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "120.00",
"quantity": 1
}
]
}Response 201
| Field | Type | Description |
|---|
id required | string (uuid) | |
receiptNumber required | string | |
currency required | string | |
subtotal required | string | |
tax required | string | |
total required | string | |
delivery | string | |
deliveryTax | string | |
deliveryTaxRatePercent | string | |
tender required | string | |
tenders | object[] | |
refundedTenders | object[] | |
locationId | string (uuid) | null | |
createdAt required | string | |
cashReceived | string | null | |
changeDue | string | null | |
taxDocumentRef required | string | null | |
taxQrPayload | string | null | |
returnReceipts | object[] | |
etaUuid required | string | null | |
refundedTotal required | string | |
refunded required | boolean | |
approvedBy | string | null | |
cashierName | string | null | |
customerName | string | null | |
refundApprovedBy | string | null | |
approvalReason | string | null | |
discount | string | |
discountName | string | null | |
discountNote | string | null | |
exchange | object | null | |
hasCustomer | boolean | |
channelOrder | string | null | |
tenderGateway | string | null | |
dryRun | true | Present and true only when a test key made the call: validated, answered, nothing saved. |
Example response 201
{
"id": "5b0f6a52-7f3a-4c8e-9a52-6d1f2f0f7e11",
"receiptNumber": "R-000412",
"currency": "EGP",
"subtotal": "120.00",
"tax": "16.80",
"total": "136.80",
"tender": "cash",
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"createdAt": "2026-10-06T09:41:12.000Z",
"cashReceived": "150.00",
"changeDue": "13.20",
"taxDocumentRef": null,
"etaUuid": null,
"refundedTotal": "0.00",
"refunded": false
}curl
curl -X POST https://counterpoint-api.fly.dev/v1/sales \
-H "Authorization: Bearer $COUNTERPOINT_KEY" \
-H "Content-Type: application/json" \
-d '{"currency":"EGP","tender":"cash","idempotencyKey":"order-48213-attempt","lines":[{"sku":"TSHIRT-BLK-M","name":"Black tee, medium","unitPrice":"120.00","quantity":1}]}'GET/v1/sales/{id}
One sale, with its lines. Scope sales:read.
A sale and the lines as they were sold: the unit price is what the customer was charged, not today's catalog price. The optional unitCost and costSource fields require reports:read in addition to sales:read; other keys do not receive them.
| Parameter | In | Type | Description |
|---|
id | path | string (uuid) | The sale id. |
Response 200
| Field | Type | Description |
|---|
id required | string (uuid) | |
receiptNumber required | string | |
currency required | string | |
subtotal required | string | |
tax required | string | |
total required | string | |
delivery | string | |
deliveryTax | string | |
deliveryTaxRatePercent | string | |
tender required | string | |
tenders | object[] | |
refundedTenders | object[] | |
locationId | string (uuid) | null | |
createdAt required | string | |
cashReceived | string | null | |
changeDue | string | null | |
taxDocumentRef required | string | null | |
taxQrPayload | string | null | |
returnReceipts | object[] | |
etaUuid required | string | null | |
refundedTotal required | string | |
refunded required | boolean | |
approvedBy | string | null | |
cashierName | string | null | |
customerName | string | null | |
refundApprovedBy | string | null | |
approvalReason | string | null | |
discount | string | |
discountName | string | null | |
discountNote | string | null | |
exchange | object | null | |
hasCustomer | boolean | |
channelOrder | string | null | |
tenderGateway | string | null | |
lines required | object[] | |
Example response 200
{
"id": "5b0f6a52-7f3a-4c8e-9a52-6d1f2f0f7e11",
"receiptNumber": "R-000412",
"currency": "EGP",
"subtotal": "120.00",
"tax": "16.80",
"total": "136.80",
"tender": "cash",
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"createdAt": "2026-10-06T09:41:12.000Z",
"cashReceived": "150.00",
"changeDue": "13.20",
"taxDocumentRef": null,
"etaUuid": null,
"refundedTotal": "0.00",
"refunded": false,
"lines": [
{
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"quantity": 1,
"unitPrice": "120.00"
}
]
}curl
curl https://counterpoint-api.fly.dev/v1/sales/5b0f6a52-7f3a-4c8e-9a52-6d1f2f0f7e11 \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
Reports
GET/v1/reports/summary
Sales summary. Scope reports:read.
Headline figures across all sales. netRevenue is ex-VAT after refunds; avgBasket counts only sales that were not fully refunded. All money is a decimal string with two places.
Response 200
| Field | Type | Description |
|---|
cogs required | string | Net cost of goods from stored sale/refund snapshots, with legacy fallback. |
estimatedCostLines | integer | Number of sale/refund lines whose missing snapshot used today's cost. |
saleCount required | integer | |
revenue required | string | |
taxCollected required | string | |
refunds required | string | |
netRevenue required | string | |
grossRevenue required | string | |
avgBasket required | string | |
fullyRefundedCount required | integer | |
todayNetRevenue required | string | |
todaySaleCount required | integer | |
discounts required | string | |
todayDiscounts required | string | |
todayDiscountedCount required | integer | |
Example response 200
{
"cogs": "30120.00",
"estimatedCostLines": 0,
"saleCount": 412,
"revenue": "58210.40",
"taxCollected": "7148.60",
"refunds": "640.00",
"netRevenue": "50421.80",
"grossRevenue": "57570.40",
"avgBasket": "122.38",
"fullyRefundedCount": 3,
"todayNetRevenue": "1830.00",
"todaySaleCount": 14,
"discounts": "2140.00",
"todayDiscounts": "96.50",
"todayDiscountedCount": 3
}curl
curl https://counterpoint-api.fly.dev/v1/reports/summary \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
Webhooks
GET/v1/webhooks
List webhook endpoints. Scope webhooks:manage.
Registered endpoints and their health. The signing secret is never returned here.
Response 200
| Field | Type | Description |
|---|
id required | string (uuid) | |
url required | string (uri) | |
events required | string[] | |
active required | boolean | |
consecutiveFailures required | integer | |
disabledReason required | string | null | |
createdAt required | string | |
Example response 200
[
{
"id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f01",
"url": "https://partner.example.com/counterpoint",
"events": [
"sale.created",
"refund.created"
],
"active": true,
"consecutiveFailures": 0,
"disabledReason": null,
"createdAt": "2026-10-01T10:00:00.000Z"
}
]curl
curl https://counterpoint-api.fly.dev/v1/webhooks \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
POST/v1/webhooks
Register a webhook endpoint. Scope webhooks:manage.
A test key validates this call and returns the result with "dryRun": true; nothing is saved.
Subscribes a URL to one or more events and returns its signing secret, once. Store it: it is needed to verify every delivery and cannot be retrieved later. See the Webhooks section for the payloads and how to verify a signature.
Request body
| Field | Type | Description |
|---|
url required | string (uri) | https only. http://localhost and http://127.0.0.1 are allowed for local development. |
events required | "sale.created" | "refund.created" | "product.created" | "product.deleted" | "product.updated" | "inventory.adjusted" | "fulfillment.updated" | "automation.fired"[] | |
Example request body
{
"url": "https://partner.example.com/counterpoint",
"events": [
"sale.created",
"refund.created"
]
}Response 201
| Field | Type | Description |
|---|
id required | string (uuid) | |
url required | string | |
events required | string[] | |
active required | boolean | |
secret required | string | The signing secret. Returned once, here, and never again. |
createdAt required | string | |
dryRun | true | Present and true only when a test key made the call: validated, answered, nothing saved. |
Example response 201
{
"id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f01",
"url": "https://partner.example.com/counterpoint",
"events": [
"sale.created",
"refund.created"
],
"active": true,
"secret": "whsec_Zk3q1xV0n8Hc2mT5pYb7LwRd9sJf4GaE",
"createdAt": "2026-10-01T10:00:00.000Z"
}curl
curl -X POST https://counterpoint-api.fly.dev/v1/webhooks \
-H "Authorization: Bearer $COUNTERPOINT_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://partner.example.com/counterpoint","events":["sale.created"]}'DELETE/v1/webhooks/{id}
Remove a webhook endpoint. Scope webhooks:manage.
A test key validates this call and returns the result with "dryRun": true; nothing is saved.
Stops deliveries to the endpoint.
| Parameter | In | Type | Description |
|---|
id | path | string (uuid) | The endpoint id. |
Response 200
| Field | Type | Description |
|---|
removed required | boolean | |
dryRun | true | Present and true only when a test key made the call: validated, answered, nothing saved. |
Example response 200
{
"removed": true
}curl
curl -X DELETE https://counterpoint-api.fly.dev/v1/webhooks/7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f01 \
-H "Authorization: Bearer $COUNTERPOINT_KEY"
Webhook events
Each delivery is a signed POST with this envelope. See Webhooks above for retries and how to verify the signature.
sale.created
A sale was recorded.
Example payload
{
"event": "sale.created",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"id": "5b0f6a52-7f3a-4c8e-9a52-6d1f2f0f7e11",
"receiptNumber": "R-000412",
"currency": "EGP",
"subtotal": "120.00",
"tax": "16.80",
"total": "136.80",
"tender": "cash",
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"lines": [
{
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"quantity": 1,
"unitPrice": "120.00"
}
],
"createdAt": "2026-10-06T09:41:12.000Z"
}
}refund.created
A refund was issued.
Example payload
{
"event": "refund.created",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"id": "6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b",
"saleId": "5b0f6a52-7f3a-4c8e-9a52-6d1f2f0f7e11",
"currency": "EGP",
"subtotal": "120.00",
"tax": "16.80",
"total": "136.80",
"method": "original",
"reasonCode": "CHANGED_MIND",
"lines": [
{
"sku": "TSHIRT-BLK-M",
"quantity": 1
}
]
}
}product.created
A product was added to the catalog.
Example payload
{
"event": "product.created",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"id": "9c1e5f2a-3b4d-4e6f-8a7b-1c2d3e4f5a6b",
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "120.00"
}
}product.deleted
An unused product was deleted.
Example payload
{
"event": "product.deleted",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"id": "9c1e5f2a-3b4d-4e6f-8a7b-1c2d3e4f5a6b",
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "120.00"
}
}product.updated
A product was edited, archived or restored.
Example payload
{
"event": "product.updated",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"id": "9c1e5f2a-3b4d-4e6f-8a7b-1c2d3e4f5a6b",
"sku": "TSHIRT-BLK-M",
"name": "Black tee, medium",
"unitPrice": "135.00",
"archived": false
}
}inventory.adjusted
Stock changed at an outlet.
Example payload
{
"event": "inventory.adjusted",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"sku": "TSHIRT-BLK-M",
"locationId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"onHand": 12,
"delta": -2,
"reason": "sale"
}
}fulfillment.updated
An order moved through fulfillment.
Example payload
{
"event": "fulfillment.updated",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"id": "8b9c0d1e-2f3a-4b4c-8d5e-6f7a8b9c0d1e",
"saleId": null,
"status": "shipped",
"carrier": "bosta",
"trackingNumber": "7234510986"
}
}automation.fired
A rule the merchant built chose to tell you something.
Example payload
{
"event": "automation.fired",
"createdAt": "2026-10-06T09:41:12.000Z",
"data": {
"rule": "Large sale alert",
"trigger": "sale.created",
"message": "A sale over EGP 5,000 was rung up",
"event": {
"total": "5400.00",
"tender": "card",
"receiptNumber": "R-000413"
}
}
}