Developers

Partner API

Read a shop's catalog, stock, customers and sales, and record sales, from your own software. Version 1.0.0, OpenAPI 3.1.

Download OpenAPIhttps://counterpoint-api.fly.dev/v1

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 window
  • X-RateLimit-Remaining: requests left in this window
  • X-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.

Pagination

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 "", 200

Meta

GET/v1/openapi.json

This document. No key needed.

The OpenAPI 3.1 description of this API. Public: no key needed. Cacheable for five minutes and served with an ETag.

Response 200
Example response 200
{
  "openapi": "3.1.0",
  "info": {
    "title": "Counterpoint Partner API"
  }
}
curl
curl https://counterpoint-api.fly.dev/v1/openapi.json

GET/v1/whoami

What this key is and may do. Scope catalog:read.

Call this first. It returns the key's name, whether it is live or test, the scopes it holds and its rate limit, so a missing scope is discovered here rather than by being refused by the endpoint that needed it. Needs catalog:read, the narrowest scope a key can usefully hold.

Response 200
FieldTypeDescription
keyName requiredstring
keyId requiredstring
live requiredboolean
scopes requiredstring[]
rateLimitPerMinute requiredinteger
availableScopes requiredobject[]
availableEvents requiredobject[]
Example response 200
{
  "keyName": "Stock feed",
  "keyId": "a1b2c3d4e5f6",
  "live": true,
  "scopes": [
    "catalog:read",
    "inventory:read"
  ],
  "rateLimitPerMinute": 120,
  "availableScopes": [
    {
      "key": "catalog:read",
      "description": "Read products, categories, brands and suppliers"
    },
    {
      "key": "catalog:write",
      "description": "Create and update products"
    }
  ],
  "availableEvents": [
    {
      "key": "sale.created",
      "description": "A sale was recorded"
    },
    {
      "key": "refund.created",
      "description": "A refund was issued"
    }
  ]
}
curl
curl https://counterpoint-api.fly.dev/v1/whoami \
  -H "Authorization: Bearer $COUNTERPOINT_KEY"

Catalog

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.

ParameterInTypeDescription
viewquery"sellable" | "catalog"sellable (default) or catalog.
Response 200
FieldTypeDescription
id requiredstring (uuid)
sku requiredstring
name requiredstring
unitPrice requiredstring
type required"standard" | "variant" | "composite"
costManualboolean
costExactstring | null
variantCount requiredinteger
barcodestring | null
coststring | null
supplierIdstring (uuid) | null
brandIdstring (uuid) | null
reorderPointinteger | null
reorderQuantityinteger | null
archivedAtstring | null
categoryIdstring (uuid) | null
finalSaleboolean
descriptionstring | null
hiddenOutletIdsstring (uuid)[]
hiddenOutletCountinteger
imageUrlstring | null
unitstring
purchaseUnitstring | null
purchaseFactornumber | null
soldOnTillboolean
parentIdstring (uuid) | null
parentNamestring | 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
FieldTypeDescription
soldOnTillboolean
sku requiredstring
name requiredstring
type"standard" | "variant" | "composite"
unitPricestring
coststring
categoryIdstring (uuid)
supplierIdstring (uuid)
brandIdstring (uuid)
barcodestring
descriptionstring
Example request body
{
  "sku": "TSHIRT-BLK-M",
  "name": "Black tee, medium",
  "unitPrice": "120.00",
  "barcode": "6221234567891"
}
Response 201
FieldTypeDescription
id requiredstring (uuid)
sku requiredstring
name requiredstring
unitPrice requiredstring
dryRuntruePresent 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
FieldTypeDescription
id requiredstring (uuid)
name requiredstring
parentId requiredstring (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
FieldTypeDescription
id requiredstring (uuid)
name requiredstring
locationType required"store" | "warehouse"
numberingPrefix requiredstring
streetstring | null
citystring | null
governatestring | null
buildingNumberstring | null
phonestring | null
taxBranchCodestring | null
archivedAtstring | null
tillMode"retail" | "fnb"
openTillsinteger
tillLimitinteger
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".

ParameterInTypeDescription
locationIdquerystring (uuid)An outlet id from GET /v1/locations.
Response 200
FieldTypeDescription
locationId requiredstring (uuid) | null
levels requiredobject[]
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
FieldTypeDescription
id requiredstring (uuid)
code requiredstring
name requiredstring
phone requiredstring | null
email requiredstring | null
onAccount required"no" | "unlimited" | "limited"
creditLimit requiredstring | null
storeCredit requiredstring
accountBalance requiredstring
customerGroup requiredstring | null
loyaltyPoints requiredinteger
tagsstring[]
marketing requiredobject
marketingChangedAt requiredobject
address requiredstring | null
notes requiredstring | null
birthday requiredstring | null
archivedAt requiredstring | 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
FieldTypeDescription
name requiredstring
phonestring
emailstring (email)
onAccount"no" | "unlimited" | "limited"
creditLimitstring
customerGroupstring
marketingWhatsappboolean
marketingSmsboolean
marketingEmailboolean
addressstring
notesstring
birthdaystring
Example request body
{
  "name": "Mona Adel",
  "phone": "+201001234567",
  "email": "mona@example.com",
  "marketingEmail": true
}
Response 201
FieldTypeDescription
id requiredstring (uuid)
code requiredstring
name requiredstring
phone requiredstring | null
email requiredstring | null
onAccount required"no" | "unlimited" | "limited"
creditLimit requiredstring | null
storeCredit requiredstring
accountBalance requiredstring
customerGroup requiredstring | null
loyaltyPoints requiredinteger
tagsstring[]
marketing requiredobject
marketingChangedAt requiredobject
address requiredstring | null
notes requiredstring | null
birthday requiredstring | null
archivedAt requiredstring | null
dryRuntruePresent 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.

ParameterInTypeDescription
limitqueryintegerPage size, 1 to 200, default 50. Invalid values are rejected in paginated mode; legacy calls clamp out-of-range values.
paginatequerybooleanReturn {sales,nextCursor} instead of the legacy array.
cursorquerystringOpaque nextCursor from the previous page. Enables pagination.
sincequerystring (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
FieldTypeDescription
locationIdstring (uuid)
currency requiredstring
registerSessionIdstring (uuid)
tender required"card" | "cash" | "cod" | "wallet" | "instapay" | "on_account" | "store_credit" | "talabat" | "shopify" | "loyalty" | "gift_card" | "layaway"
lines requiredobject[]
customerIdstring (uuid)
discountobject
discountGroupIdstring (uuid)
discountNotestring
customerPhonestring
giftCardCodestring
remainderTender"card" | "cash" | "cod" | "wallet" | "instapay" | "talabat" | "gift_card"
loyaltyPointsinteger
paymentLinkIdstring (uuid)
buyerTaxIdstring
buyerNationalIdstring
buyerNamestring
cashReceivedstring
staffIdstring (uuid)
approvalIdstring (uuid)
idempotencyKey requiredstring
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
FieldTypeDescription
id requiredstring (uuid)
receiptNumber requiredstring
currency requiredstring
subtotal requiredstring
tax requiredstring
total requiredstring
deliverystring
deliveryTaxstring
deliveryTaxRatePercentstring
tender requiredstring
tendersobject[]
refundedTendersobject[]
locationIdstring (uuid) | null
createdAt requiredstring
cashReceivedstring | null
changeDuestring | null
taxDocumentRef requiredstring | null
taxQrPayloadstring | null
returnReceiptsobject[]
etaUuid requiredstring | null
refundedTotal requiredstring
refunded requiredboolean
approvedBystring | null
cashierNamestring | null
customerNamestring | null
refundApprovedBystring | null
approvalReasonstring | null
discountstring
discountNamestring | null
discountNotestring | null
exchangeobject | null
hasCustomerboolean
channelOrderstring | null
tenderGatewaystring | null
dryRuntruePresent 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.

ParameterInTypeDescription
idpathstring (uuid)The sale id.
Response 200
FieldTypeDescription
id requiredstring (uuid)
receiptNumber requiredstring
currency requiredstring
subtotal requiredstring
tax requiredstring
total requiredstring
deliverystring
deliveryTaxstring
deliveryTaxRatePercentstring
tender requiredstring
tendersobject[]
refundedTendersobject[]
locationIdstring (uuid) | null
createdAt requiredstring
cashReceivedstring | null
changeDuestring | null
taxDocumentRef requiredstring | null
taxQrPayloadstring | null
returnReceiptsobject[]
etaUuid requiredstring | null
refundedTotal requiredstring
refunded requiredboolean
approvedBystring | null
cashierNamestring | null
customerNamestring | null
refundApprovedBystring | null
approvalReasonstring | null
discountstring
discountNamestring | null
discountNotestring | null
exchangeobject | null
hasCustomerboolean
channelOrderstring | null
tenderGatewaystring | null
lines requiredobject[]
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
FieldTypeDescription
cogs requiredstringNet cost of goods from stored sale/refund snapshots, with legacy fallback.
estimatedCostLinesintegerNumber of sale/refund lines whose missing snapshot used today's cost.
saleCount requiredinteger
revenue requiredstring
taxCollected requiredstring
refunds requiredstring
netRevenue requiredstring
grossRevenue requiredstring
avgBasket requiredstring
fullyRefundedCount requiredinteger
todayNetRevenue requiredstring
todaySaleCount requiredinteger
discounts requiredstring
todayDiscounts requiredstring
todayDiscountedCount requiredinteger
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
FieldTypeDescription
id requiredstring (uuid)
url requiredstring (uri)
events requiredstring[]
active requiredboolean
consecutiveFailures requiredinteger
disabledReason requiredstring | null
createdAt requiredstring
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
FieldTypeDescription
url requiredstring (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
FieldTypeDescription
id requiredstring (uuid)
url requiredstring
events requiredstring[]
active requiredboolean
secret requiredstringThe signing secret. Returned once, here, and never again.
createdAt requiredstring
dryRuntruePresent 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.

ParameterInTypeDescription
idpathstring (uuid)The endpoint id.
Response 200
FieldTypeDescription
removed requiredboolean
dryRuntruePresent 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"
    }
  }
}