iVendNextDevelopers Request a sandbox

A button on a Desk record (a Desk action) — the recipe

A button on a Desk record, end to end

A stock user presses Assign pick route on a Sales Order, picks a colleague, and the partner's pick task opens.

A Desk action is a button on a shop record in iVendNext Desk. When a user presses it, your service does its work on that record and tells Desk what to show. Examples: Assign pick route on a Sales Order, Recheck unit barcodes on a Batch, Allocate cash from a Customer. This recipe is for a partner, or our own Custom Development team, building one.

Reference implementation: samples/s16_pick_route/ — Assign pick route.

When to use it. It is step 3 of the screen ladder, after configuration and a connected app: use it when a user should start your work from the record they are looking at. The button, its dialog and the answer are the shop's own screens: you ship no JavaScript into Desk, ever. Your service does the work through its own API calls; the answer only tells Desk what to show.

The four rules, here. Your answer never writes: your service writes before it answers, with its own key and its own rights. It never changes a submitted record except through a field the record allows after submit, or a record of your own linked to it. It is safe to run twice (the press carries a key for that). It never touches money.

Steps

  1. Declare it in your manifest (recipes/manifest.md, guide/07_manifests.md § The extension points' kinds). Three parts: your service, the button, and — usually — a record type of your own and a Connections link, so what you make shows under the shop's record:

    "extension_subscriptions": [{"key": "route", "point": "desk_action", "label": "Pick route service",
                                 "endpoint_url": "https://your.server/press/route", "timeout_seconds": 5}],
    "desk_actions": [{"record_type": "Sales Order", "label": "Assign pick route", "group": "Warehouse",
                      "placement": "both", "service": "route", "roles": ["Stock User", "Stock Manager"],
                      "on_draft": 0, "on_submitted": 1, "states": ["To Deliver and Bill", "To Deliver"],
                      "inputs": [{"fieldname": "picker", "label": "Picker", "fieldtype": "Link", "options": "User", "reqd": 1},
                                 {"fieldname": "priority", "label": "Priority", "fieldtype": "Select", "options": "Normal\nUrgent"}],
                      "send_fields": "name, customer, set_warehouse, delivery_date, items(item_code, qty, warehouse)"}],
    "links": [{"doctype": "Sales Order", "link_doctype": "PP PCK Pick Task", "link_fieldname": "sales_order", "group": "Picking"}]
    
    • placement: form, list (the list's Actions menu, on several records at once) or both. A bulk press needs list or both.
    • send_fields is the shop's consent. The shop reads it before it switches your service on: name exactly what you need, and nothing more. Money leaves as whole minor units with its currency; a quantity leaves in thousandths (2 units arrive as 2000).
    • The manifest installs switched off: your service unconsented and off, the button off. The service's signing secret is handed back once, at install.

    The Connections link is what puts your record under the shop's record. Here the pick task is listed under Picking on the order:

    the task under the order's Connections

  2. What the shop does after install. A System Manager opens your service (Retail Extension Subscription), reads what is sent, ticks the consent and switches it on, then switches the button on (Retail Desk Action). The server stamps the consent (who, when, for which address and which list). A change to the address or the list clears it.

    Once it is on, the button sits in the record's menu — here in the Warehouse menu of a submitted order:

    the Warehouse menu

  3. Your service checks every press is the shop's. The press is one POST to your address, body {"point": "desk_action/1", "context": {…}}, with X-iVendNext-Timestamp and X-iVendNext-Signature = base64 HMAC-SHA256 of <timestamp>.<raw body> with the secret. Check it over the raw bytes, before you parse anything, and refuse a timestamp more than five minutes from yours:

    want = base64.b64encode(hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).digest())
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(want, sig.encode()):
        return 401
    
  4. Do the work with your own key, then answer. The context carries the user, their language, the inputs, each record's type, name and declared fields, and an idempotency_key. S16 creates one pick task per order over the record API as its integration user (recipes/integration_user.md), stores the key on the task, and finds that task again on a retry:

    name = tenant.find_task(order, key) or tenant.create_task(plan(record, inputs, key))
    return {"message": {"title": "Route assigned", "body": "2 lines across 2 warehouses for …", "level": "success"},
            "open_record": {"doctype": "PP PCK Pick Task", "name": name}}
    

    Give your integration role only what the work needs. S16's role reads and creates its own pick tasks (no write: it never edits one), and does not read Sales Orders: everything it needs arrives in the press. On a retry, answer about the task that exists (its picker), not about what the dialog sends this time.

    The pick task the service made holds the order, the picker, the priority and the lines in walking order:

    the pick task, opened

  5. Answer in Desk's closed vocabulary. A message (title ≤ 60, body ≤ 280, level info · success · warning) plus at most one next step: open_record (it must exist and the user must be allowed to read it), open_page (an address under a partner page placed on Desk) or refresh: true. One bad part refuses the whole answer. The contract is desk_action.v1.schema.json in the extension-point library; S16 checks every answer it gives against it (tests/test_contract.py, which needs the library's contract files beside it; where they are not, it skips and says so).

    Desk shows your message as the shop's own:

    the answer

  6. Press it the way Desk does. Desk calls ivendnext_mpos.api.desk_action.run(action, names, inputs, idempotency_key). The server re-checks everything the button showed, on the saved record. The user fills in the shop's own dialog with the inputs you declared:

    the dialog, filled in

    What to expect:

    • One order: your answer shows, and your record opens. The order itself is unchanged. Your record's owner is your integration user, not the user who pressed.
    • Your service receives exactly the fields you declared, and nothing else.
    • The same press twice (one key) gives the same answer and one record.
    • Several orders from the list: one press, one answer, a record for each.
    • An answer Desk cannot use (say, open_record naming a record that does not exist) is refused whole, logged, and the user is told to check the record.
    • A user without the role, a record in the wrong state, or an input value the button does not accept: the server refuses in its own words and logs the refusal. Your service is not called.
  7. When your service is down or slow. Desk waits 3 seconds unless you raise it (at most 10; S16 declares 5) and then says "⟨label⟩ did not answer in time. Check the record before you try again." — it never claims nothing changed, because you may have written before you went quiet. A retry from the same dialog carries the same key, so your idempotency makes it safe. Set your timeout to cover your own API writes. We have not yet tested this case end to end.

  8. Test it. Unit-test your core and service on your laptop with presses signed the way the shop signs them (samples/s16_pick_route/tests/, 10 tests), and check your answers against the contract. Then press it on a sandbox.

What the shop needs from you, and what it checks

You give The shop checks
an HTTPS address its server trusts (a real certificate) the address is consented with the field list; a private address is refused on a customer's site
a signature check on every press — (yours to do)
send_fields, as small as the work allows the consent text is built from it, and a change clears the consent
roles that may press re-checked on the server at every press, with the record's state and every condition

Technical notes

What was proven where

Proven on our test bench on 2026-10-07.

Figures beside the run that measured them

All on our test bench, 2026-10-07, run pp-20261007T044702Z-52374 (confirming …T043702Z-24249): install → the shop's switch-on → S1–S4, the three refusals, the forged press → uninstall with the schema as found (the Extension Log keeps its press rows, by design) — setup 9/9, S16 14/14, teardown 6/6. A press, end to end (the shop's call, over HTTPS, the service writing one or three pick tasks over the record API, the answer judged): 39–207 ms, over two runs, on the four answered presses (the Extension Log's latency_ms; the bench and the service on one Mac mini, so a real network adds its own round trip). Your timeout must cover your own API writes: S16 declares 5 seconds.

This page in the kit