A value captured at the scan, and a code before a new customer (capture) — the recipe
A capture asks your HTTPS service at a moment the cashier did not choose, at one of two events:
- On scan of an item your filter names (its item group, or an item field that is set), before its line goes on the sale: you can let it on, put a value on the line (a warranty reference, a registration number), ask a yes/no question, or refuse it.
- On customer add, before a new customer is created: you can allow it, refuse it, or challenge — you send the customer a one-time code and the till shows a code field. The customer is created only after you say the code is right.
This recipe is for an iVendNext Partner, or the CitiXsys Consulting Team, building one.
Reference implementation: samples/s20_capture/ — Warranty capture and a member code: a warranty reference on every handset line, and a code by text message before a member is added.
When to use it. On scan you shape the cashier's flow; you are not a booking gate (for a rule that must hold when the sale is booked, use a check before payment, recipes/rule_guard.md). On customer add the server is the gate on the till's own doors: no customer is created there until it has recorded your answer for that cashier, that name and that phone. Two exceptions, both flagged on the Sales to review report and never refused, because the customer has already paid: a customer added offline is created when the sale syncs, and the receipt's add a contact after a walk-in sale (one customer per paid sale) is created too. A customer made in iVendNext Desk or through another API is outside the gate. A rule that must never let a barred number in needs your own reconciliation as well.
The four rules, here. No answer writes a customer field, and none moves money: no price, total or tender is sent, and nothing you answer changes one. A value you put on a line is annotation. A failed capture never fails the scan.
Steps
-
Declare one service per event in your manifest, each at its own address: the address then tells your service which secret to check the signature with, before it reads the body.
"extension_subscriptions": [ {"key": "on_scan", "point": "capture", "label": "Warranty capture", "endpoint_url": "https://your.server/cap/scan", "timeout_seconds": 1.5, "down_answer": "allow_and_flag", "settings": {"event": "scan", "item_groups": ["iPhone", "iPad"]}}, {"key": "on_customer", "point": "capture", "label": "Member code", "endpoint_url": "https://your.server/cap/customer", "timeout_seconds": 3, "down_answer": "refuse", "settings": {"event": "customer_add"}}]- On scan:
item_groups(at most 50; a group names every group below it) and/oritem_field(an Item field that is set); 1.5 seconds unless raised, 3 at most. - On customer add: 3 seconds unless raised, 10 at most.
down_answer:allow_and_flag(the default: the scan goes on, or the customer may be created, and the log row is flagged) orrefuse.- Both install switched off; each signing secret is handed back once.
- On scan:
-
What the shop does after install. A System Manager reads what each service is sent, ticks the consent, chooses the POS Profiles and switches it on. For a value on the line, the shop keeps an active line attribute (Transaction Item Attribute) and tells you its name.
-
Check every call is the shop's — the same signature as every extension point (base64 HMAC-SHA256 of
<timestamp>.<raw body>), with each subscription's own secret, over the raw bytes, before you parse anything (samples/s20_capture/service/app.py). -
On scan: answer from the scan's own values. You receive the line's item code, item group, quantity in thousandths, unit, batch, serial and the code scanned — and no line id: the line does not exist until your answer lets it on. Two units of one model scanned by their item barcode look the same, so S20 makes a new reference for every scan. A value for the line:
{"verdict": "allow", "line_attributes": [{"name": "Warranty ref", "value": "WR-3F9A0C21B7"}]}The cashier sees it on a confirm sheet first; on Add with these details it lands on that line, which is then its own line (a later scan of the same item never merges into it).
-
On customer add: refuse, allow or challenge.
{"verdict": "challenge", "prompt": "Ask the customer for the 6-digit code we just sent.", "length": 6}Keep the code against
context.challenge_idand send it to the customer through your own gateway (S20's sample writes it to a file; your service calls its SMS or WhatsApp provider). The till sends what the customer reads out as{"event": "verify", "challenge_id": …, "code": …}; you answer{"verdict": "ok"}or{"verdict": "refuse", "reason": "That code is not right."}. The product counts the tries: three wrong codes end the check, and a challenge lives ten minutes. Keep your codes for at most ten minutes too. -
What the cashier sees.
Your answer The till scan · allowwith valuesa confirm sheet, then the item with your value on its line scan · confirm+questiona beep and the question; No leaves the item off scan · refuse+reason"This item can't be added" and your sentence customer · refuse+reason"This customer can't be added" and your sentence customer · challengea code field with your prompt; a wrong code: your reason and "⟨n⟩ tries left." a create without your recorded answer refused by the server: "Check the customer's code first." — the till then asks you -
When your service is down or slow. On scan,
allow_and_flaglets the line on and flags it;refusekeeps the item off. On customer add,refusereads "⟨label⟩ did not answer. The customer can't be added until it does." The scan never fails because of you: a capture that cannot run applies your declared answer. -
Test it. Unit-test the core and the service with calls signed the way the shop signs them (
samples/s20_capture/tests/), and check every answer against the contract. Then scan and add a customer on a sandbox.
Technical notes
What was proven where
Proven on our test bench on 2026-10-08 (iVendNext POS app at 577a8d41), run pp-20261008T125051Z-84203, 16 of 16 checks, over HTTPS, through the till's own calls as the cashier (api.mpos_cart.scan, api.capture.customer_add, api.capture.verify, api.customer.quick_create):
| Case | Result |
|---|---|
| Install | two services switched off, one per event; each secret handed back once |
| A handset of the named groups scanned | the service's reference lands as a value of the shop's attribute ({"transaction_item_attribute": "SET5 Warranty ref", "attribute_value": "WR-CED7D457F2"}) — the product hands it to the till in that shape |
| An item outside the groups | nobody is called, no log row |
| The service down · the capture itself failing | the scan still finds the item and the line goes on, flagged (allow_and_flag) |
| A new customer without a phone · with a barred phone | refused in the service's words |
| A new phone | challenged: "Ask the customer for the 6-digit code we just sent.", a code sent to that phone |
| A wrong code | refused, two tries left |
| The create before the right code | refused by the server: "Check the customer's code first." — no customer |
| The right code | ok, with the create's token |
| That token offered for another name | refused — no customer of that name |
| The create with the token | the customer is created, name and phone as typed |
| The same phone again | challenged again (no phone skips the code) |
| Three wrong codes | the check ends; even the right code is then refused ("This code check has ended. Add the customer again.") |
The captured reference on a booked sale: the money kit's label for this sample. Results: GREEN on 2026-10-08 (run pp-20261008T125956Z-562): the booked line carries the service's reference; every other sale as the baseline. With S20 left off, the line carries none and the label goes RED.
Not run here: a customer added offline (created at sync, flagged) and the receipt's contact after payment; a confirm or refuse on scan; the camera's pause.