Browse the docs

Build

Webhooks

Receive events, verify signatures, and survive retries and duplicates.

Scanimart pushes events to an HTTPS endpoint you register — a new order, a cancellation, a finished sale. Each is a JSON POST:

HTTP
POST /scanimart/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Scanimart-Connect/1.0 (+https://developers.scanimart.com)
Scanimart-Event-Id: evt_8f14e45fceea167a5a36dedd4bea2543
Scanimart-Event-Type: order.placed
Scanimart-Mode: live
Scanimart-Delivery-Id: 88213
Scanimart-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"api_version":"2026-10-01","created_at":"…","data":{…},"id":"evt_8f14…","mode":"live","object":"event","store_id":1042,"type":"order.placed"}

Every event type and its data is listed in the event reference.

GO orders and in-store transaction alerts

Events belong to stores actively connected to your POS company, in the endpoint's live or test mode. Disconnecting a store stops its order and sale deliveries, including queued retries. Reconnecting starts a new event window; it does not replay the previous connection's backlog.

Event Meaning for your POS
order.placed A Scanimart GO delivery order has been paid. Show it to the store for acceptance or rejection.
order.payment_failed A pending online checkout timed out without payment. Do not fulfil or book it.
order.payment_recovered A late GO payment succeeded; order.placed follows.
order.cancelled The GO order was cancelled or rejected. Void any provisional bill.
sale.completed with data.sale.channel: SCANIMART_ONLINE The GO order was delivered. Book the completed sale.
sale.completed with data.sale.channel: SCANIMART_INSTORE An in-store payment was verified, or staff confirmed collected cash. Book the completed sale.
sale.exit_verified Staff checked an in-store basket at the exit. This is informational, not a second sale.

Picking, packing and delivery updates also arrive as events so your POS can display progress. Staff perform picking and packing in the Scanimart Staff app.

There is currently no dedicated event for every failed, abandoned or still-pending in-store payment attempt. An order with status: COMPLETED alone does not prove payment: check payment.paid, and book from sale.completed or the completed-sales feed. A missing webhook alone does not prove failure; use GET /v1/events and the store's sales feed to recover missed deliveries.

Registering an endpoint

In the dashboard (Webhooks) or with POST /v1/webhook-endpoints. You can have up to ten per environment, and each can ask for only some event types (events; empty means all). The response carries the endpoint's signing secret, whsec_… — store it with your other secrets.

Endpoints must be public HTTPS URLs. Addresses inside private networks are refused, both when you register and again before every delivery.

Verify every signature

Anyone can POST to your URL. Before you trust a body, check Scanimart-Signature:

  1. Split the header on , into t=<unix seconds> and one or more v1=<hex>.
  2. Compute HMAC-SHA256 of the string "<t>.<raw request body>" with your signing secret as the key, hex-encoded.
  3. Accept if it equals any v1 (compare in constant time) and t is within five minutes of now.

Use the raw body exactly as received. Parsing the JSON and serialising it again changes the bytes and the signature will not match.

Node.js
// Express. express.raw keeps the body as the exact bytes Scanimart sent.
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.SCANIMART_WEBHOOK_SECRET;

function verify(header, rawBody, secret, toleranceSeconds = 300) {
  if (!header) return false;
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  return signatures.some(
    (s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
}

app.post("/scanimart/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req.get("Scanimart-Signature"), req.body, SECRET)) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  if (await alreadyProcessed(event.id)) return res.sendStatus(200);
  if (event.type === "sale.completed") await bookSale(event); // a 2xx says it is booked
  else await queue.add(event); // a durable queue: saved before you answer, worked on after
  res.sendStatus(200);
});
Python
# Flask. request.get_data() is the raw body.
import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["SCANIMART_WEBHOOK_SECRET"]

def verify(header, raw_body, secret, tolerance=300):
    if not header:
        return False
    parts = [p.strip().split("=", 1) for p in header.split(",")]
    stamps = [v for k, v in parts if k == "t"]
    signatures = [v for k, v in parts if k == "v1"]
    try:
        t = int(stamps[0])
    except (IndexError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)

@app.post("/scanimart/webhooks")
def scanimart_webhook():
    raw = request.get_data()
    if not verify(request.headers.get("Scanimart-Signature"), raw, SECRET):
        return "", 400
    event = json.loads(raw)
    if not already_processed(event["id"]):
        if event["type"] == "sale.completed":
            book_sale(event)  # a 2xx says it is booked
        else:
            enqueue(event)  # a durable queue: saved before you answer, worked on after
    return "", 200

The official SDKs verify signatures for you.

Answer a bad signature with a 4xx. The go-live checklist sends you a deliberately mis-signed event and checks that you refuse it.

Save it, answer, then work

Reply with any 2xx within 10 seconds. Anything else — a timeout, a 5xx, a redirect — counts as a failure, and the delivery is retried.

A 2xx tells Scanimart the event is safe with you, and it stops retrying. So make it durable first — write it to your database, or to a queue that survives a restart — and only then answer. Do the slow work (printing the bill, updating screens) from there, after you have answered. An event held only in memory is lost if your process dies after answering.

sale.completed is the one event where the answer says more: a 2xx means the sale is booked, and Scanimart stops subtracting its units from the stock counts you push. For it, the durable record is the booked sale itself — book it, then answer. If your POS cannot book a sale within 10 seconds, leave sale.completed out of the endpoint's events and pull sales instead, acknowledging each one once it is booked.

Retries

A failed delivery is retried with backoff: after 1 minute, 5, 15, then 1, 3, 6, 12 and 24 hours, and 24 hours again — ten attempts over about three days. After the last one the delivery is given up on (DEAD), and you can replay it from the dashboard or POST /v1/webhook-deliveries/{id}/retry.

An endpoint that has failed continuously for three days is switched off, and the dashboard says why. Switch it back on once it is fixed; deliveries resume, and GET /v1/events has everything in between.

Duplicates and order

Delivery is at-least-once: the same event can arrive twice (a timeout after you processed it, a manual replay). Use id — also in Scanimart-Event-Id — to ignore repeats.

Events can also arrive out of order, especially after a retry. Do not assume order.accepted lands before order.packed; each event carries the whole order as it was at that moment, so compare created_at and keep the newest.

Testing your endpoint

Send a test event on the endpoint's page in the dashboard (or POST /v1/webhook-endpoints/{id}/test) posts a sample of any event type to it right away and shows what your endpoint answered. Tick bad signature to check that you refuse forgeries. Test events carry the header Scanimart-Test.

Rolling the secret

POST /v1/webhook-endpoints/{id}/roll-secret (or Roll secret in the dashboard) issues a new secret immediately. To roll without dropping events, register a second endpoint, deploy its secret, then delete the first.

Missed something?

GET /v1/events?since=… returns the same event bodies, oldest first, for the last 30 days. Poll it after an outage, or as a nightly safety net.